diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a0a1ecf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.auto_codex +.docker +.idea +.vscode +node_modules +node_modules.root-owned-backup +dist +app-dist +test-app-dist +coverage +playwright-report +test-results +.cache +tmp +public/.codex_chat +docs/assets/worklogs +*.log +*.tsbuildinfo +.env +.env.* +!.env.example diff --git a/AGENTS.md b/AGENTS.md index 02fff07..da61bad 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ * 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다 * 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다 +* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다 +* `test.sm-home.cloud` nginx 프록시는 **화면 `/`만 `5174` 앱 테스트 서버로 보내고, `/api/`와 `/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다 +* `test.sm-home.cloud`의 `/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다 * 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다 * `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 @@ -44,6 +47,7 @@ * `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 * 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다 +* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준의 로컬 작업으로 연결한다 * 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다 diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 0000000..40be0a1 --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,29 @@ +FROM node:22.22.2-bookworm AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npm run build:test-app + +FROM node:22.22.2-bookworm AS runtime + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=5173 +ENV APP_DIST_DIR=/tmp/ai-code-test-app-dist +ENV WORK_SERVER_URL=http://host.docker.internal:3100 +ENV VITE_DISABLE_APP_UPDATE=true + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --legacy-peer-deps + +COPY --from=build /app/scripts ./scripts +COPY --from=build /tmp/ai-code-test-app-dist /tmp/ai-code-test-app-dist + +EXPOSE 5173 + +CMD ["npm", "run", "preview:test-app", "--", "--host", "0.0.0.0", "--port", "5173", "--strictPort"] diff --git a/README.md b/README.md index 56ea6e9..114dc32 100755 --- a/README.md +++ b/README.md @@ -17,6 +17,34 @@ npm install npm run dev ``` +## 확인용 Preview 컨테이너 + +실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml`을 사용합니다. + +```bash +docker compose -f docker-compose.preview.yml up -d --build +``` + +- 기본 접속 주소: `http://127.0.0.1:4173` +- 소스 코드는 이미지 빌드 시점에 복사되므로, 로컬 파일 변경이 컨테이너에 바로 섞이지 않습니다. +- API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다. +- 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다. + +## 테스트용 컨테이너 운영 기준 + +- 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다. +- 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다. +- `https://test.sm-home.cloud/` 운영 기준은 `화면 / -> 5174 앱 테스트 서버`, `/api/` 및 `/ws/chat` -> `127.0.0.1:3100 work-server` 입니다. +- 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다. +- 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다. +- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다. + +```bash +docker compose -f docker-compose.preview.yml up -d --build --force-recreate --no-deps preview-app +``` + +- 저장소 설정에 없는 임시 테스트 컨테이너가 남아 있으면 정리 대상입니다. + ## PhotoPrism 루트 `docker-compose.yml`에는 PhotoPrism와 MariaDB 서비스가 포함되어 있습니다. diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml new file mode 100644 index 0000000..32abaf9 --- /dev/null +++ b/docker-compose.preview.yml @@ -0,0 +1,16 @@ +services: + preview-app: + container_name: ai-code-app-preview + build: + context: . + dockerfile: Dockerfile.preview + ports: + - "${PREVIEW_APP_PORT:-4173}:5173" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + PORT: 5173 + APP_DIST_DIR: /tmp/ai-code-test-app-dist + WORK_SERVER_URL: ${WORK_SERVER_URL:-http://host.docker.internal:3100} + VITE_DISABLE_APP_UPDATE: "true" + restart: unless-stopped diff --git a/docs/chat-frontend-rewrite-plan.md b/docs/chat-frontend-rewrite-plan.md deleted file mode 100644 index 8a1a979..0000000 --- a/docs/chat-frontend-rewrite-plan.md +++ /dev/null @@ -1,249 +0,0 @@ -# Chat Frontend Rewrite Plan - -## Goal - -Rebuild the chat frontend around explicit feature boundaries instead of one large panel. The rewrite keeps the current server contract for now, but removes direct API coupling from UI components and preserves the current mobile visual structure. - -## Non-Goals - -The first rewrite wave does not change: - -1. Work server REST endpoint shapes -2. Work server WebSocket event payload shapes -3. Existing chat database schema -4. Current mobile interaction model and visual hierarchy - -## Current Pain Points - -The existing frontend mixes too many concerns in the same render tree: - -1. Session routing and panel selection -2. Conversation list fetch and sort -3. Conversation detail recovery -4. Composer draft and upload lifecycle -5. WebSocket connection and reconnect -6. Runtime dashboard fetch and live updates -7. Error log loading -8. Notification unread sync -9. Visibility, focus, reconnect, and page restore sync - -This creates three classes of failures: - -1. Render loops from unstable effect dependencies -2. Request storms from duplicate fetch paths -3. Partial outages where one failing concern blanks the whole chat workspace - -## Rewrite Strategy - -The rewrite is frontend-first, but not frontend-only in architecture. The new frontend must assume that API latency and WebSocket reconnects can fail, and each feature controller must degrade independently. - -### Guiding Rules - -1. UI components do not call REST helpers directly -2. UI components do not build WebSocket URLs directly -3. One feature controller owns one feature's network lifecycle -4. Shell state never performs data fetches -5. Mobile layout is preserved while data flow is replaced under it - -## Feature Inventory - -### 1. Workspace Shell - -Responsibilities: - -1. Hold `chat | runtime | errors` selection -2. Hold active session id -3. Hold mobile split-pane visibility -4. Compose feature panes - -Rules: - -1. No direct REST calls -2. No direct socket usage -3. No data caching - -### 2. Conversation List - -Responsibilities: - -1. Load room summaries -2. Search and sort locally -3. Create, rename, delete, and select rooms -4. Expose unread and processing badges - -Rules: - -1. One fetch source -2. One short-lived in-flight dedupe layer -3. No room detail fetch inside the list controller - -### 3. Conversation Room - -Responsibilities: - -1. Load one room detail -2. Merge server messages with optimistic local state -3. Recover state after reconnect -4. Mark replies as read - -Rules: - -1. Only one active detail request at a time -2. Detail loading must never fan out into list reloads -3. Loading and recovery state must be local to the active room - -### 4. Composer - -Responsibilities: - -1. Hold draft and attachments -2. Submit queue or direct requests -3. Retry, cancel, and delete pending items -4. Manage optimistic user/request messages - -Rules: - -1. No list-wide refresh on send -2. No runtime refresh coupled to draft input - -### 5. Live Connection - -Responsibilities: - -1. Open and maintain the shared chat socket -2. Route message, job, runtime, and activity events -3. Reconnect with bounded recovery -4. Publish connection state through a shared adapter - -Rules: - -1. No duplicate context writes -2. Background transitions are throttled -3. Reconnect only restores the active room by default - -### 6. Runtime - -Responsibilities: - -1. Load runtime snapshot -2. Show queue and active jobs -3. Load per-job detail -4. Support remove and cancel actions - -Rules: - -1. Runtime refresh is separate from room detail refresh -2. Runtime failure must not blank the chat room UI - -### 7. Error Viewer - -Responsibilities: - -1. Load error log list -2. Render error detail and resource previews - -Rules: - -1. Fully isolated from chat room state - -### 8. Notification Integration - -Responsibilities: - -1. Unread badges -2. Notification center list/detail -3. Offline room notifications - -Rules: - -1. No room detail polling from notification badge refresh -2. No direct dependency from notification UI to chat room rendering - -## New Frontend Layers - -### A. UI Layer - -Files under `components/` and pane files. - -Responsibilities: - -1. Render props only -2. Emit user actions only - -### B. Feature Controller Layer - -Files under `hooks/`. - -Responsibilities: - -1. Manage one feature's state machine -2. Translate UI actions to gateway calls -3. Own loading and error state - -### C. Gateway Layer - -Files under `data/`. - -Responsibilities: - -1. Wrap all chat REST calls -2. Wrap all chat socket entry points -3. Normalize fallback behavior and timeouts in one place - -This is the critical separation that the old frontend does not have. - -## Target Folder Shape - -```text -src/app/main/chatV2/ - ChatWorkspaceV2.tsx - types.ts - data/ - chatGateway.ts - chatConnectionGateway.ts - hooks/ - useChatWorkspaceState.ts - useConversationListController.ts - useConversationRoomController.ts - useComposerController.ts - useRuntimeController.ts - useNotificationController.ts - components/ - ConversationListPane.tsx - ConversationRoomPane.tsx - Composer.tsx - RuntimePane.tsx - ErrorPane.tsx -``` - -## Migration Waves - -### Wave 1 - -1. Freeze mobile layout -2. Introduce chatV2 gateway layer -3. Move list/detail/runtime access behind the gateway - -### Wave 2 - -1. Replace list controller -2. Replace room controller -3. Replace composer controller - -### Wave 3 - -1. Reconnect runtime and notifications through new controllers -2. Remove old `MainChatPanel` effect chains - -### Wave 4 - -1. Make `MainChatPanel` a thin compatibility wrapper or replace it entirely - -## Success Criteria - -1. Main load triggers one list fetch -2. Opening one room triggers one detail fetch -3. No direct browser fallback to external `:3100` ports on remote hosts -4. WebSocket and REST routing live in one gateway boundary -5. One pane can fail without blanking the others -6. Mobile layout matches the pre-rewrite visual structure diff --git a/docs/features/project-setup.md b/docs/features/project-setup.md deleted file mode 100755 index 786a648..0000000 --- a/docs/features/project-setup.md +++ /dev/null @@ -1,100 +0,0 @@ -# 프로젝트 구성 개요 - -## 목적 - -현재 저장소의 화면 구조와 문서 체계를 빠르게 파악하기 위한 최신 개요 문서입니다. - -## 기술 스택 - -- React -- Vite -- TypeScript -- Ant Design -- Recharts -- React Router - -## 최상위 앱 구조 - -- `src/app/main`: 메인 앱 프레임, 상단 메뉴, 사이드바, 본문, 검색 연동 -- `src/features`: 프로젝트 전용 기능 화면 -- `src/components`: 재사용 가능한 UI 컴포넌트 -- `src/widgets`: 샘플/위젯 단위 UI -- `docs`: 기능/컴포넌트/작업일지 문서 -- `etc/servers/work-server`: Plan API 연동 서버 자산 - -## 현재 주요 기능 축 - -### Docs - -- `docs/**/*.md`를 수집해 문서 화면에 노출 -- 작업일지, 기능 문서, 컴포넌트 문서를 같은 흐름으로 탐색 -- `docs/features` 아래 문서는 `Docs / 기능문서` 메뉴에서 동적으로 확인 가능 - -### APIs - -- 컴포넌트 샘플 -- 위젯 샘플 - -### Plans - -- Plan 자동화 목록/상세 -- release 검수 -- 차트 -- 스케줄 -- 히스토리 확장 영역 - -### Chat - -- Codex Live -- 에러 로그 - -`Codex Live`는 현재 프로젝트 환경의 `main_project`를 기준 저장소로 사용합니다. 소스 수정이 필요하면 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. - -일반 채팅 요청과 작업메모 반영 요청도 같은 기준을 따르며, 별도 브랜치 생성 없이 현재 프로젝트 루트에서 바로 수정하는 것을 기본 동작으로 사용합니다. - -채팅에서 제공되는 파일/문서/이미지/코드 리소스와 첨부 파일은 세션별로 `public/.codex_chat//resource/...` 아래에 노출됩니다. - -### Play - -- Layout Editor -- 저장된 레이아웃 기록 - -## Plan 기능 구조 - -Plan 관련 코드는 `src/features/planBoard`에 집중되어 있습니다. - -- `PlanBoardPage.tsx`: 자동화 목록과 상세 편집 -- `ReleaseReviewPage.tsx`: release 검수 -- `PlanSchedulePage.tsx`: 반복 등록 스케줄 -- `charts.tsx`: 작업 추이 차트 -- `api.ts`: API 통신 -- `types.ts`: 상태/타입 정의 - -## 문서 구조 - -- `docs/worklogs`: 날짜별 작업 기록 -- `docs/features`: 기능 설명과 운영 가이드 -- `docs/components`: 공통 컴포넌트 설명 -- `docs/templates`: 기능/작업일지 템플릿 - -현재 `docs/features`의 핵심 문서는 다음과 같습니다. - -- `project-setup.md` -- `search-layer.md` -- `plan-board-review.md` -- `plan-automation.md` -- `plan-schedule.md` -- `plan-usage.md` - -## 검색/문서 연계 - -- 통합 검색 옵션은 `src/app/main/mainView/searchOptions.ts`에서 구성 -- 문서, Plan 화면, 컴포넌트 샘플, 위젯 샘플을 하나의 검색 엔트리로 제공 -- 선택 시 해당 메뉴와 포커스 대상으로 바로 이동 - -## 운영 메모 - -- 기능 문서는 구현 파일명과 메뉴명을 그대로 써서 찾기 쉽게 유지 -- `docs/features` 변경분이 보이지 않으면 현재 선택한 Docs 폴더가 `기능문서`인지 먼저 확인 -- Plan 관련 변경은 문서와 라우팅/검색 옵션을 함께 확인 -- 스케줄, release 검수, 차트처럼 화면이 분리된 기능은 개별 문서를 유지 diff --git a/docs/worklogs/2026-04-21.md b/docs/worklogs/2026-04-21.md new file mode 100644 index 0000000..0de226f --- /dev/null +++ b/docs/worklogs/2026-04-21.md @@ -0,0 +1,34 @@ +# 2026-04-21 작업일지 + +## 오늘 작업 + +- 화면 캡처 추가 예정 + +## 스크린샷 + +![feature-chat-live](../assets/worklogs/2026-04-21/feature-chat-live.png) + +## 소스 + +### 파일 1: `path/to/file.tsx` + +- 변경 목적과 핵심 수정 내용을 한 줄로 정리 + +```diff +# 이 파일의 핵심 diff +- before ++ after +``` + +### 파일 2: `path/to/another-file.ts` + +- 필요 없으면 이 섹션은 삭제 + +## 실행 커맨드 + +```bash +``` + +## 변경 파일 + +- diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index 245406d..0a302d0 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; -import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js'; +import { z } from 'zod'; +import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js'; export async function registerAppConfigRoutes(app: FastifyInstance) { app.get('/api/app-config', async () => { @@ -11,6 +12,44 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { }; }); + app.get('/api/chat-types', async () => { + const chatTypes = await getChatTypesConfig(); + + return { + ok: true, + chatTypes, + }; + }); + + app.put('/api/chat-types', async (request, reply) => { + try { + let payload: unknown = request.body ?? {}; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + + const parsed = z.object({ + chatTypes: z.array(z.unknown()), + }).parse(payload ?? {}); + + const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes); + + return { + ok: true, + chatTypes: savedChatTypes, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.', + }); + } + }); + app.put('/api/app-config', async (request, reply) => { try { let payload: unknown = request.body ?? {}; diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 51bdf3f..c37841b 100755 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -6,16 +6,14 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; -import { getChatRuntimeController } from '../services/chat-service.js'; +import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js'; import { createChatConversation, deleteUnansweredChatConversationRequest, deleteChatConversation, ensureChatConversationTables, getChatConversation, - listChatConversationActivityLogs, - listChatConversationMessages, - listChatConversationRequests, + listChatConversationDetailPage, listChatConversations, markChatConversationResponsesRead, updateChatConversationContext, @@ -136,7 +134,7 @@ function sanitizeChatAttachmentFileName(fileName: string) { } function resolveChatAttachmentRepoPath() { - return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH); + return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH); } function getClientIdHeader(request: { headers: Record }) { @@ -314,6 +312,7 @@ export async function registerChatRoutes(app: FastifyInstance) { 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(), contextLabel: z.string().trim().max(200).optional(), contextDescription: z.string().trim().max(2000).optional(), notifyOffline: z.boolean().optional(), @@ -324,6 +323,7 @@ export async function registerChatRoutes(app: FastifyInstance) { sessionId: payload.sessionId, clientId: clientId || null, title: payload.title ?? '새 대화', + chatTypeId: payload.chatTypeId ?? null, contextLabel: payload.contextLabel ?? null, contextDescription: payload.contextDescription ?? null, notifyOffline: payload.notifyOffline ?? true, @@ -353,30 +353,20 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const messageLimit = query.limit ?? 500; - const messages = await listChatConversationMessages(params.sessionId, { + const messageLimit = query.limit ?? 6; + const detailPage = await listChatConversationDetailPage(params.sessionId, { limit: messageLimit, beforeMessageId: query.beforeMessageId ?? null, }); - const requests = await listChatConversationRequests(params.sessionId, 500); - const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500); - const oldestLoadedMessageId = messages[0]?.id ?? null; - const hasOlderMessages = - oldestLoadedMessageId != null - ? (await listChatConversationMessages(params.sessionId, { - limit: 1, - beforeMessageId: oldestLoadedMessageId, - })).length > 0 - : false; return { ok: true, item, - messages, - requests, - activityLogs, - oldestLoadedMessageId, - hasOlderMessages, + messages: detailPage.messages, + requests: detailPage.requests, + activityLogs: detailPage.activityLogs, + oldestLoadedMessageId: detailPage.oldestLoadedMessageId, + hasOlderMessages: detailPage.hasOlderMessages, }; }); @@ -447,6 +437,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }).parse(request.params ?? {}); const payload = z.object({ title: z.string().trim().min(1).max(200).optional(), + chatTypeId: 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(), @@ -464,6 +455,7 @@ export async function registerChatRoutes(app: FastifyInstance) { const item = await updateChatConversationContext(params.sessionId, { title: payload.title ?? current.title, clientId: current.clientId, + chatTypeId: payload.chatTypeId ?? current.chatTypeId, contextLabel: payload.contextLabel ?? current.contextLabel, contextDescription: payload.contextDescription ?? current.contextDescription, notifyOffline: payload.notifyOffline ?? current.notifyOffline, @@ -489,6 +481,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } + await getActiveChatService()?.forgetSession(params.sessionId); const deleted = await deleteChatConversation(params.sessionId); return { diff --git a/etc/servers/work-server/src/routes/crud.test.ts b/etc/servers/work-server/src/routes/crud.test.ts new file mode 100644 index 0000000..d9ab3e4 --- /dev/null +++ b/etc/servers/work-server/src/routes/crud.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { maskCrudRowSensitiveFields } from './crud.js'; + +test('maskCrudRowSensitiveFields masks password and related login identifier fields in credential-like rows', () => { + const rows = [ + { + service_name: 'legacy-admin', + login_id: 'admin-master', + password: 'super-secret-password', + note: 'keep this as-is', + }, + ]; + + const masked = maskCrudRowSensitiveFields(rows); + + assert.deepEqual(masked, [ + { + service_name: 'legacy-admin', + login_id: 'ad********er', + password: 'su*****************rd', + note: 'keep this as-is', + }, + ]); +}); + +test('maskCrudRowSensitiveFields keeps generic ids unchanged when no credential secret field exists', () => { + const rows = [ + { + id: 12, + user_id: 'owner-01', + title: 'normal business row', + }, + ]; + + const masked = maskCrudRowSensitiveFields(rows); + + assert.deepEqual(masked, rows); +}); + +test('maskCrudRowSensitiveFields masks nested secret values recursively', () => { + const payload = { + profile: { + username: 'how2ice', + access_token: 'tok_1234567890', + }, + }; + + assert.deepEqual(maskCrudRowSensitiveFields(payload), { + profile: { + username: 'ho***ce', + access_token: 'to**********90', + }, + }); +}); diff --git a/etc/servers/work-server/src/routes/crud.ts b/etc/servers/work-server/src/routes/crud.ts index 06f42bc..b3715ae 100755 --- a/etc/servers/work-server/src/routes/crud.ts +++ b/etc/servers/work-server/src/routes/crud.ts @@ -39,6 +39,61 @@ const deleteSchema = z.object({ }); const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']); +const secretFieldPattern = /(?:^|_|-)(?:password|passwd|pwd|passcode|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)(?:$|_|-)/i; +const loginFieldPattern = + /(?:^|_|-)(?:login[_-]?id|login[_-]?name|username|user[_-]?name|user[_-]?id|account[_-]?id|account[_-]?name|admin[_-]?id|admin[_-]?name|member[_-]?id|member[_-]?name|email)(?:$|_|-)/i; + +function maskCredentialValue(value: string) { + const trimmed = value.trim(); + + if (!trimmed) { + return value; + } + + if (trimmed.length <= 2) { + return '*'.repeat(trimmed.length); + } + + if (trimmed.length <= 4) { + return `${trimmed[0]}${'*'.repeat(trimmed.length - 2)}${trimmed.at(-1) ?? ''}`; + } + + return `${trimmed.slice(0, 2)}${'*'.repeat(Math.max(2, trimmed.length - 4))}${trimmed.slice(-2)}`; +} + +function shouldMaskLoginField(fieldName: string, row: Record) { + if (!loginFieldPattern.test(fieldName)) { + return false; + } + + return Object.keys(row).some((candidateField) => secretFieldPattern.test(candidateField)); +} + +export function maskCrudRowSensitiveFields(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => maskCrudRowSensitiveFields(item)) as T; + } + + if (!value || typeof value !== 'object') { + return value; + } + + const row = value as Record; + const result: Record = {}; + + Object.entries(row).forEach(([fieldName, fieldValue]) => { + if (typeof fieldValue === 'string') { + if (secretFieldPattern.test(fieldName) || shouldMaskLoginField(fieldName, row)) { + result[fieldName] = maskCredentialValue(fieldValue); + return; + } + } + + result[fieldName] = maskCrudRowSensitiveFields(fieldValue); + }); + + return result as T; +} function applyFilters(query: Knex.QueryBuilder, filters: z.infer[] = []) { filters.forEach((filter) => { @@ -138,7 +193,7 @@ export async function registerCrudRoutes(app: FastifyInstance) { ok: true, table, count: rows.length, - rows, + rows: maskCrudRowSensitiveFields(rows), }; }); @@ -150,7 +205,7 @@ export async function registerCrudRoutes(app: FastifyInstance) { return { ok: true, table, - rows: inserted, + rows: maskCrudRowSensitiveFields(inserted), }; }); @@ -197,7 +252,7 @@ export async function registerCrudRoutes(app: FastifyInstance) { ok: true, table, count: rows.length, - rows, + rows: maskCrudRowSensitiveFields(rows), }; }); @@ -214,7 +269,7 @@ export async function registerCrudRoutes(app: FastifyInstance) { ok: true, table, count: rows.length, - rows, + rows: maskCrudRowSensitiveFields(rows), }; }); } diff --git a/etc/servers/work-server/src/server.ts b/etc/servers/work-server/src/server.ts index 128f797..51b8ebe 100755 --- a/etc/servers/work-server/src/server.ts +++ b/etc/servers/work-server/src/server.ts @@ -2,7 +2,7 @@ import { env } from './config/env.js'; import { db } from './db/client.js'; import { createApp } from './app.js'; import { ChatService } from './services/chat-service.js'; -import { clearAllChatConversationJobStates } from './services/chat-room-service.js'; +import { clearAllChatConversationJobStates, ensureChatConversationTables } from './services/chat-room-service.js'; import { shutdownNotificationProvider } from './services/notification-service.js'; import { PlanWorker } from './workers/plan-worker.js'; @@ -13,6 +13,7 @@ app.server.on('upgrade', chatService.attachUpgradeHandler()); async function start() { try { + await ensureChatConversationTables(); await clearAllChatConversationJobStates(); await app.listen({ host: '0.0.0.0', 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 a683734..28cd6bf 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -1,6 +1,7 @@ import { db } from '../db/client.js'; export const APP_CONFIG_TABLE = 'app_configs'; +const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; async function ensureAppConfigTable() { const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); @@ -49,6 +50,14 @@ export async function getAppConfig() { return row.config_json ?? {}; } +function normalizeConfigRecord(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} as Record; + } + + return value as Record; +} + export type AppConfigSnapshot = { chat?: { maxContextMessages?: number; @@ -107,25 +116,49 @@ export async function getAppConfigSnapshot(): Promise { export async function upsertAppConfig(config: Record) { await ensureAppConfigTable(); + const nextConfig = normalizeConfigRecord(config); const existing = await db(APP_CONFIG_TABLE).first(); if (!existing) { const rows = await db(APP_CONFIG_TABLE) .insert({ - config_json: config, + config_json: nextConfig, updated_at: db.fn.now(), }) .returning('*'); - return rows[0]?.config_json ?? config; + return rows[0]?.config_json ?? nextConfig; } + const mergedConfig = { + ...normalizeConfigRecord(existing.config_json), + ...nextConfig, + }; + const rows = await db(APP_CONFIG_TABLE) .update({ - config_json: config, + config_json: mergedConfig, updated_at: db.fn.now(), }) .returning('*'); - return rows[0]?.config_json ?? config; + return rows[0]?.config_json ?? mergedConfig; +} + +export async function getChatTypesConfig() { + const config = await getAppConfig(); + const normalized = normalizeConfigRecord(config); + const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY]; + return Array.isArray(chatTypes) ? chatTypes : null; +} + +export async function upsertChatTypesConfig(chatTypes: unknown[]) { + const current = normalizeConfigRecord(await getAppConfig()); + const nextConfig = { + ...current, + [CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [], + }; + + await upsertAppConfig(nextConfig); + return nextConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]; } diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index c50db03..315e56a 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildChatConversationRequestPatchFromMessage, + isVisibleConversationMessage, mergeChatConversationRequestStatus, shouldClearConversationJobState, selectChatConversationResponseCandidate, @@ -26,6 +27,28 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa ); }); +test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => { + assert.equal( + isVisibleConversationMessage({ + id: 1, + author: 'system', + text: '응답을 준비하고 있습니다.', + timestamp: '2026-04-22 10:00:00', + }), + false, + ); + + assert.equal( + isVisibleConversationMessage({ + id: 2, + author: 'system', + text: '[[activity-log]]\n작업을 시작했습니다.', + timestamp: '2026-04-22 10:00:01', + }), + true, + ); +}); + test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => { assert.deepEqual( buildChatConversationRequestPatchFromMessage({ diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index 5850914..e5aece6 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -14,6 +14,7 @@ const conversationPayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), clientId: z.string().trim().max(120).nullable().optional(), title: z.string().trim().max(200).nullable().optional(), + chatTypeId: 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(), @@ -32,6 +33,7 @@ export type ChatConversationItem = { sessionId: string; clientId: string | null; title: string; + chatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; @@ -88,6 +90,14 @@ export type ChatConversationActivityLogItem = { updatedAt: string | null; }; +export type ChatConversationDetailPage = { + messages: StoredChatMessage[]; + requests: ChatConversationRequestItem[]; + activityLogs: ChatConversationActivityLogItem[]; + oldestLoadedMessageId: number | null; + hasOlderMessages: boolean; +}; + type ChatConversationRequestStatusPatch = { requestId: string; status?: ChatConversationRequestStatus; @@ -113,6 +123,25 @@ type ChatConversationClientPreference = { lastReadResponseMessageId: number | null; }; +function normalizeDateTimeValue(value: unknown) { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + const normalized = String(value).trim(); + + if (!normalized) { + return null; + } + + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString(); +} + function createPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; @@ -143,6 +172,7 @@ function mapConversationRow(row: Record): ChatConversationItem sessionId: String(row.session_id ?? ''), 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), 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), @@ -151,11 +181,11 @@ function mapConversationRow(row: Record): ChatConversationItem currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message), currentQueueSize: Number(row.current_queue_size ?? 0), - currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), lastMessagePreview: String(row.last_message_preview ?? ''), - createdAt: String(row.created_at ?? ''), - updatedAt: String(row.updated_at ?? ''), - lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), + createdAt: normalizeDateTimeValue(row.created_at) ?? '', + updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', + lastMessageAt: normalizeDateTimeValue(row.last_message_at), }; } @@ -169,7 +199,7 @@ function mapMessageRow(row: Record): StoredChatMessage { }; } -function isVisibleConversationMessage(message: StoredChatMessage) { +export function isVisibleConversationMessage(message: StoredChatMessage) { if (message.author !== 'system') { return true; } @@ -177,6 +207,12 @@ function isVisibleConversationMessage(message: StoredChatMessage) { return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } +function applyVisibleConversationMessageCondition(builder: any) { + builder.whereNot('author', 'system').orWhere((nestedBuilder: any) => { + nestedBuilder.where('author', '=', 'system').andWhere('text', 'like', `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n%`); + }); +} + function mapClientPreferenceRow(row: Record): ChatConversationClientPreference { return { sessionId: String(row.session_id ?? ''), @@ -203,10 +239,10 @@ function mapRequestRow(row: Record): ChatConversationRequestIte responseText: String(row.response_text ?? ''), hasResponse, canDelete, - createdAt: String(row.created_at ?? ''), - updatedAt: String(row.updated_at ?? ''), - answeredAt: row.answered_at == null ? null : String(row.answered_at), - terminalAt: row.terminal_at == null ? null : String(row.terminal_at), + createdAt: normalizeDateTimeValue(row.created_at) ?? '', + updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', + answeredAt: normalizeDateTimeValue(row.answered_at), + terminalAt: normalizeDateTimeValue(row.terminal_at), }; } @@ -560,7 +596,7 @@ async function getLatestPreviewableMessageMap(sessionIds: string[]) { messageMap.set(sessionId, { text: String(row.text ?? ''), - createdAt: row.created_at == null ? null : String(row.created_at), + createdAt: normalizeDateTimeValue(row.created_at), }); } @@ -594,7 +630,7 @@ async function getLatestRequestPreviewMap(sessionIds: string[]) { requestMap.set(sessionId, { text: userText, - createdAt: row.created_at == null ? null : String(row.created_at), + createdAt: normalizeDateTimeValue(row.created_at), }); } @@ -672,6 +708,7 @@ export async function ensureChatConversationTables() { table.string('session_id', 120).primary(); table.string('client_id', 120).nullable().index(); table.string('title', 200).notNullable().defaultTo('새 대화'); + table.string('chat_type_id', 120).nullable(); table.string('context_label', 200).nullable(); table.text('context_description').nullable(); table.boolean('notify_offline').notNullable().defaultTo(false); @@ -690,6 +727,7 @@ export async function ensureChatConversationTables() { const requiredConversationColumns: Array<[string, (table: any) => void]> = [ ['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()], ['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)], @@ -864,7 +902,6 @@ export async function ensureChatConversationTables() { } export async function getChatConversation(sessionId: string, clientId?: string | null) { - await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); @@ -878,7 +915,7 @@ export async function getChatConversation(sessionId: string, clientId?: string | shouldClearConversationJobState({ currentRequestId, currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], - currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(currentRequestId), request: currentRequestId ? await db(CHAT_CONVERSATION_REQUEST_TABLE) @@ -894,8 +931,8 @@ export async function getChatConversation(sessionId: string, clientId?: string | status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), - terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at), - updatedAt: requestRow.updated_at == null ? null : String(requestRow.updated_at), + terminalAt: normalizeDateTimeValue(requestRow.terminal_at), + updatedAt: normalizeDateTimeValue(requestRow.updated_at), } : null, ) @@ -966,7 +1003,6 @@ export async function getChatConversation(sessionId: string, clientId?: string | } export async function createChatConversation(payload: z.input) { - await ensureChatConversationTables(); const parsed = conversationPayloadSchema.parse(payload); const normalizedClientId = normalizeClientId(parsed.clientId); const notifyOffline = parsed.notifyOffline ?? true; @@ -975,6 +1011,7 @@ export async function createChatConversation(payload: z.input { + const query = db(CHAT_CONVERSATION_TABLE) + .select('*') + .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST') + .orderByRaw('last_message_at DESC NULLS LAST') + .orderByRaw('updated_at DESC NULLS LAST') + .orderByRaw('created_at DESC NULLS LAST') + .limit(normalizedLimit); - if (normalizedClientId) { - query.where((builder) => { - builder - .where({ client_id: normalizedClientId }) - .orWhereExists( - db(CHAT_CONVERSATION_CLIENT_TABLE) - .select(db.raw('1')) - .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) - .andWhere({ client_id: normalizedClientId }), - ); - }); + if (targetClientId) { + query.where((builder) => { + builder + .where({ client_id: targetClientId }) + .orWhereExists( + db(CHAT_CONVERSATION_CLIENT_TABLE) + .select(db.raw('1')) + .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) + .andWhere({ client_id: targetClientId }), + ); + }); + } + + return query; + }; + + let rows = await buildConversationListQuery(normalizedClientId); + + // Browser storage reset can regenerate client_id and hide existing rooms. + // When that happens, fall back to the recent global list so the user can recover. + if (normalizedClientId && rows.length === 0) { + conversationListScopeClientId = null; + rows = await buildConversationListQuery(null); } - let rows = await query; - const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean); const currentRequestIds = Array.from( new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)), @@ -1096,7 +1146,7 @@ export async function listChatConversations( status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), - terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at), + terminalAt: normalizeDateTimeValue(requestRow.terminal_at), }, ]), ); @@ -1105,7 +1155,7 @@ export async function listChatConversations( shouldClearConversationJobState({ currentRequestId: String(row.current_request_id ?? ''), currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], - currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')), request: requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null, @@ -1149,7 +1199,7 @@ export async function listChatConversations( current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); - rows = await query.clone(); + rows = await buildConversationListQuery(conversationListScopeClientId); } } @@ -1231,7 +1281,6 @@ export async function listChatConversationMessages( beforeMessageId?: number | null; } = {}, ) { - await ensureChatConversationTables(); const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200))); const normalizedBeforeMessageId = Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 @@ -1244,20 +1293,226 @@ export async function listChatConversationMessages( if (normalizedBeforeMessageId !== null) { builder.where('message_id', '<', normalizedBeforeMessageId); } + + builder.andWhere((visibilityBuilder) => { + applyVisibleConversationMessageCondition(visibilityBuilder); + }); }) .orderBy('message_id', 'desc') .orderBy('id', 'desc') .limit(normalizedLimit); const latestRows = await query; - return latestRows - .reverse() - .map((row: Parameters[0]) => mapMessageRow(row)) - .filter((message: ReturnType) => isVisibleConversationMessage(message)); + return latestRows.reverse().map((row: Parameters[0]) => mapMessageRow(row)); +} + +async function listChatConversationActivityLogsByRequestIds( + sessionId: string, + requestIds: string[], +): Promise { + const normalizedSessionId = sessionId.trim(); + const normalizedRequestIds = Array.from(new Set(requestIds.map((item) => item.trim()).filter(Boolean))); + + if (!normalizedSessionId || normalizedRequestIds.length === 0) { + return []; + } + + const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) + .select('session_id', 'request_id', 'text', 'line_no', 'created_at') + .where({ session_id: normalizedSessionId }) + .whereIn('request_id', normalizedRequestIds) + .orderBy('request_id', 'asc') + .orderBy('line_no', 'asc') + .orderBy('id', 'asc'); + + const activityMap = new Map(); + + for (const row of rows) { + const requestId = String(row.request_id ?? '').trim(); + + if (!requestId) { + continue; + } + + const existing = activityMap.get(requestId); + + if (existing) { + existing.lines.push(String(row.text ?? '')); + existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; + continue; + } + + activityMap.set(requestId, { + sessionId: String(row.session_id ?? normalizedSessionId), + requestId, + lines: [String(row.text ?? '')], + updatedAt: normalizeDateTimeValue(row.created_at), + }); + } + + return normalizedRequestIds + .map((requestId) => activityMap.get(requestId)) + .filter(Boolean) as ChatConversationActivityLogItem[]; +} + +async function resolveConversationRequestCursor(sessionId: string, beforeMessageId: number) { + const normalizedSessionId = sessionId.trim(); + const normalizedBeforeMessageId = Math.trunc(beforeMessageId); + + if (!normalizedSessionId || normalizedBeforeMessageId <= 0) { + return null; + } + + const directRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('request_id', 'created_at') + .where({ session_id: normalizedSessionId }) + .andWhere((builder) => { + builder.where('user_message_id', normalizedBeforeMessageId).orWhere('response_message_id', normalizedBeforeMessageId); + }) + .first(); + + if (directRequestRow) { + return { + requestId: String(directRequestRow.request_id ?? '').trim(), + createdAt: normalizeDateTimeValue(directRequestRow.created_at) ?? '', + }; + } + + const messageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .select('client_request_id') + .where({ + session_id: normalizedSessionId, + message_id: normalizedBeforeMessageId, + }) + .first(); + const linkedRequestId = String(messageRow?.client_request_id ?? '').trim(); + + if (!linkedRequestId) { + return null; + } + + const linkedRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('request_id', 'created_at') + .where({ + session_id: normalizedSessionId, + request_id: linkedRequestId, + }) + .first(); + + if (!linkedRequestRow) { + return null; + } + + return { + requestId: String(linkedRequestRow.request_id ?? '').trim(), + createdAt: normalizeDateTimeValue(linkedRequestRow.created_at) ?? '', + }; +} + +export async function listChatConversationDetailPage( + sessionId: string, + options: { + limit?: number; + beforeMessageId?: number | null; + } = {}, +): Promise { + const normalizedSessionId = sessionId.trim(); + const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); + const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 6))); + const normalizedBeforeMessageId = + Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 + ? Math.trunc(options.beforeMessageId as number) + : null; + const requestCursor = + normalizedBeforeMessageId == null + ? null + : await resolveConversationRequestCursor(normalizedSessionId, normalizedBeforeMessageId); + + const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ session_id: normalizedSessionId }) + .whereNot('status', 'removed') + .modify((builder) => { + if (!requestCursor) { + return; + } + + builder.andWhere((cursorBuilder) => { + cursorBuilder + .where('created_at', '<', requestCursor.createdAt) + .orWhere((sameTimeBuilder) => { + sameTimeBuilder.where('created_at', '=', requestCursor.createdAt).andWhere('request_id', '<', requestCursor.requestId); + }); + }); + }) + .orderBy('created_at', 'desc') + .orderBy('request_id', 'desc') + .limit(normalizedLimit); + + const orderedRequestRows = [...requestRows].reverse(); + const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); + const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean); + + if (requestIds.length === 0) { + return { + messages: [], + requests, + activityLogs: [], + oldestLoadedMessageId: null, + hasOlderMessages: false, + }; + } + + const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .select('*') + .where({ session_id: normalizedSessionId }) + .whereIn('client_request_id', requestIds) + .andWhere((builder) => { + applyVisibleConversationMessageCondition(builder); + }) + .orderBy('message_id', 'asc') + .orderBy('id', 'asc'); + const messages = messageRows.map((row: Parameters[0]) => mapMessageRow(row)); + const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds); + const oldestLoadedMessageId = + requests.reduce((oldestId, request) => { + const candidateIds = [request.userMessageId, request.responseMessageId].filter( + (value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0, + ); + + if (candidateIds.length === 0) { + return oldestId; + } + + const nextCandidateId = Math.min(...candidateIds); + return oldestId == null ? nextCandidateId : Math.min(oldestId, nextCandidateId); + }, null) ?? messages[0]?.id ?? null; + const oldestRequest = requests[0] ?? null; + const hasOlderMessages = oldestRequest + ? Boolean( + await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ session_id: normalizedSessionId }) + .whereNot('status', 'removed') + .andWhere((builder) => { + builder + .where('created_at', '<', oldestRequest.createdAt) + .orWhere((sameTimeBuilder) => { + sameTimeBuilder.where('created_at', '=', oldestRequest.createdAt).andWhere('request_id', '<', oldestRequest.requestId); + }); + }) + .first(), + ) + : false; + + return { + messages, + requests, + activityLogs, + oldestLoadedMessageId, + hasOlderMessages, + }; } export async function listChatConversationRequests(sessionId: string, limit = 200) { - await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); @@ -1270,8 +1525,6 @@ export async function listChatConversationRequests(sessionId: string, limit = 20 } export async function getChatConversationRequest(sessionId: string, requestId: string) { - await ensureChatConversationTables(); - const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); @@ -1313,7 +1566,6 @@ export async function appendChatConversationMessage( conversationPayload: z.input, messagePayload: z.input, ) { - await ensureChatConversationTables(); const conversation = conversationPayloadSchema.parse(conversationPayload); const message = conversationMessagePayloadSchema.parse(messagePayload); @@ -1353,6 +1605,7 @@ export async function appendChatConversationMessage( .update({ client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, title: nextTitle, + chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null, context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null, context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null, notify_offline: @@ -1390,7 +1643,6 @@ export async function appendChatConversationMessage( } export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) { - await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const normalizedLine = line.trim(); @@ -1426,7 +1678,6 @@ export async function listChatConversationActivityLogs( sessionId: string, limitRequests = 500, ): Promise { - await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { @@ -1469,7 +1720,7 @@ export async function listChatConversationActivityLogs( if (existing) { existing.lines.push(String(row.text ?? '')); - existing.updatedAt = row.created_at == null ? existing.updatedAt : String(row.created_at); + existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; continue; } @@ -1477,7 +1728,7 @@ export async function listChatConversationActivityLogs( sessionId: String(row.session_id ?? normalizedSessionId), requestId, lines: [String(row.text ?? '')], - updatedAt: row.created_at == null ? null : String(row.created_at), + updatedAt: normalizeDateTimeValue(row.created_at), }); } @@ -1494,8 +1745,6 @@ export async function updateChatConversationJobState( clear?: boolean; }, ) { - await ensureChatConversationTables(); - const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); if (!current) { @@ -1547,8 +1796,6 @@ export async function upsertChatConversationRequest( responseText?: string | null; }, ) { - await ensureChatConversationTables(); - const normalizedSessionId = sessionId.trim(); const normalizedRequestId = payload.requestId.trim(); @@ -1698,7 +1945,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu author: String(row.author ?? 'codex') as StoredChatMessage['author'], text: String(row.text ?? ''), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), - createdAt: row.created_at == null ? null : String(row.created_at), + createdAt: normalizeDateTimeValue(row.created_at), })); for (let index = 0; index < requestRows.length; index += 1) { @@ -1713,12 +1960,12 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu const candidate = selectChatConversationResponseCandidate( { requestId, - createdAt: String(requestRow.created_at ?? ''), + createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '', responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), }, nextRequestRow ? { - createdAt: String(nextRequestRow.created_at ?? ''), + createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '', } : undefined, responseMessages, @@ -1785,8 +2032,6 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu } export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) { - await ensureChatConversationTables(); - const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) @@ -1866,8 +2111,6 @@ export async function clearAllChatConversationJobStates() { } export async function deleteChatConversation(sessionId: string) { - await ensureChatConversationTables(); - return db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: sessionId.trim() }).del(); @@ -1878,7 +2121,6 @@ export async function deleteChatConversation(sessionId: string) { } export async function getChatConversationClientPreference(sessionId: string, clientId: string) { - await ensureChatConversationTables(); const row = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), @@ -1890,8 +2132,6 @@ export async function getChatConversationClientPreference(sessionId: string, cli } export async function listChatConversationOfflineNotificationClientIds(sessionId: string) { - await ensureChatConversationTables(); - const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), @@ -1905,7 +2145,6 @@ export async function listChatConversationOfflineNotificationClientIds(sessionId } export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) { - await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); await db(CHAT_CONVERSATION_CLIENT_TABLE) @@ -1927,8 +2166,6 @@ export async function upsertChatConversationClientPreference(sessionId: string, } export async function markChatConversationResponsesRead(sessionId: string, clientId: string) { - await ensureChatConversationTables(); - const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); 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 08d976c..db1e8a4 100755 --- a/etc/servers/work-server/src/services/chat-runtime-service.ts +++ b/etc/servers/work-server/src/services/chat-runtime-service.ts @@ -352,6 +352,50 @@ class ChatRuntimeService { this.emit(); } + clearSession(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + let changed = false; + + for (const [requestId, item] of this.runningJobs.entries()) { + if (item.sessionId !== normalizedSessionId) { + continue; + } + + this.runningJobs.delete(requestId); + this.controls.delete(requestId); + changed = true; + } + + for (const [requestId, item] of this.queuedJobs.entries()) { + if (item.sessionId !== normalizedSessionId) { + continue; + } + + this.queuedJobs.delete(requestId); + this.controls.delete(requestId); + changed = true; + } + + for (const [requestId, item] of this.archivedJobs.entries()) { + if (item.sessionId !== normalizedSessionId) { + continue; + } + + this.archivedJobs.delete(requestId); + this.controls.delete(requestId); + changed = true; + } + + if (changed) { + this.emit(); + } + } + private buildTerminalLog(status: ChatRuntimeTerminalStatus) { if (status === 'completed') { return '실행이 완료되었습니다.'; 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 4e83943..5c35597 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -150,7 +150,8 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', 'response.diff', ); - assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')); + assert.match(rewritten, new RegExp(`\\[\\[preview:${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\]$`, 'm')); + assert.doesNotMatch(rewritten, /diff 리소스 경로:/); assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n'); }); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 486f0f7..a11c072 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -22,6 +22,7 @@ import { updateChatConversationContext, } from './chat-room-service.js'; import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js'; +import { hasErrorLogViewAccessToken } from './error-log-service.js'; import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; import { createNotificationMessage } from './notification-message-service.js'; import { @@ -162,6 +163,7 @@ type ChatSessionState = { clientId: string | null; socket: WebSocket | null; lastSeenAt: number; + isDeleted: boolean; context: ChatContext | null; queue: Array<{ requestId: string; @@ -188,12 +190,17 @@ type ActiveChatExecution = { }; let activeRuntimeController: ChatRuntimeController | null = null; +let activeChatService: ChatService | null = null; const activeChatProcessRegistry = new Map(); export function getChatRuntimeController() { return activeRuntimeController; } +export function getActiveChatService() { + return activeChatService; +} + const SOCKET_PATH = '/ws/chat'; const KST_TIME_ZONE = 'Asia/Seoul'; const STREAM_CAPTURE_LIMIT = 256 * 1024; @@ -275,8 +282,11 @@ function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: try { const targetUrl = new URL(pageUrl); + targetUrl.pathname = '/chat/live'; targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat'); targetUrl.searchParams.set('sessionId', sessionId); + targetUrl.searchParams.delete('chatView'); + targetUrl.searchParams.delete('runtimeRequestId'); return targetUrl.toString(); } catch { return fallbackUrl.toString(); @@ -397,6 +407,15 @@ function createRequestId() { return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) { + const queryToken = url.searchParams.get('accessToken')?.trim(); + const headerToken = Array.isArray(request.headers['x-access-token']) + ? String(request.headers['x-access-token'][0] ?? '').trim() + : String(request.headers['x-access-token'] ?? '').trim(); + + return hasErrorLogViewAccessToken(queryToken || headerToken); +} + function hashRequestId(value: string) { let hash = 0; @@ -454,13 +473,19 @@ function isSocketOpen(socket: WebSocket | null | undefined) { return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN); } -function closeSocketSafely(logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string) { +function closeSocketSafely( + logger: FastifyBaseLogger, + socket: WebSocket | null | undefined, + message: string, + code = 1000, + reason = 'replaced', +) { if (!socket) { return; } try { - socket.close(); + socket.close(code, reason); } catch (error) { logger.warn(error, message); } @@ -1293,15 +1318,8 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) { return output; } - const lines = ['diff 리소스 경로:']; - - if (uniqueUrls.length === 1) { - lines.push(uniqueUrls[0]!); - } else { - lines.push(...uniqueUrls.map((url, index) => `${index + 1}. ${url}`)); - } - - return `${output}\n\n${lines.join('\n')}`; + const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n'); + return `${output}\n\n${hiddenPreviewTags}`; } export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { @@ -1452,18 +1470,22 @@ function buildAgenticCodexPrompt( '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', + '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.', '- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.', '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', '', - '현재 화면 문맥:', + '채팅 유형 문맥(우선 적용):', + `- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`, + `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`, + `- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`, + '- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.', + '', + '참고 화면 정보:', `- pageTitle: ${context?.pageTitle ?? '없음'}`, `- topMenu: ${context?.topMenu ?? '없음'}`, `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`, - `- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`, - `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`, - `- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`, '', isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:', ...(isTemplateRequest @@ -1627,7 +1649,7 @@ async function runAgenticCodexReply( onProgress?: (text: string) => void, onActivity?: (line: string) => void, ) { - const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; + 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); const appConfig = await getAppConfigSnapshot(); const recentHistory = @@ -2109,60 +2131,7 @@ async function buildCodexReply( onProgress?: (text: string) => void, onActivity?: (line: string) => void, ) { - const normalized = input.toLowerCase(); - - if (isAutomationRegistrationCountRequest(input)) { - return buildAutomationRegistrationCountReply(); - } - - if (isAutomationRegistrationDefinitionRequest(input)) { - return buildAutomationRegistrationDefinitionReply(); - } - - if (shouldUseAgenticCodexReply(input)) { - return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); - } - - const requestsPlanContext = - isWorklogRequest(input) || - isPlanDetailRequest(input) || - normalized.includes('preview') || - normalized.includes('링크') || - normalized.includes('url') || - input.includes('변경') || - input.includes('스크린샷'); - const parsedPlanContext = parsePlanContext(context, input); - let snapshot = parsedPlanContext.planId ? await loadPlanSnapshot(parsedPlanContext.planId) : null; - - if (!snapshot && parsedPlanContext.workId) { - const planItem = await findPlanItemByWorkId(parsedPlanContext.workId); - snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null; - } - - if (!snapshot && parsedPlanContext.previewUrl) { - const planItem = await findPlanItemByPreviewUrl(parsedPlanContext.previewUrl); - snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null; - } - - if (!snapshot && requestsPlanContext) { - const latestPlanItem = await findLatestPlanItem(); - snapshot = latestPlanItem?.id ? await loadPlanSnapshot(Number(latestPlanItem.id)) : null; - } - - const isPlanPage = - context?.topMenu === 'plans' || - context?.pageId?.startsWith('plans:') || - isPlanDetailRequest(input); - - if (isPlanPage && snapshot) { - return buildPlanReply(context, input, snapshot); - } - - if (!shouldUseTemplateMacroReply(context, input)) { - return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); - } - - return buildGenericReply(context, input, snapshot); + return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); } export class ChatService { @@ -2174,6 +2143,7 @@ export class ChatService { private readonly unsubscribeRuntimeBroadcast: () => void; constructor(private readonly logger: FastifyBaseLogger) { + activeChatService = this; activeRuntimeController = { getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId), cancelJob: (requestId) => this.cancelRuntimeJob(requestId), @@ -2215,6 +2185,9 @@ export class ChatService { close() { activeRuntimeController = null; + if (activeChatService === this) { + activeChatService = null; + } this.unsubscribeRuntimeBroadcast(); for (const execution of activeChatProcessRegistry.values()) { @@ -2248,6 +2221,7 @@ export class ChatService { clientId: clientId?.trim() || null, socket: null, lastSeenAt: Date.now(), + isDeleted: false, context: null, queue: [], activeRequestCount: 0, @@ -2282,6 +2256,10 @@ export class ChatService { } private persistConversationMessage(session: ChatSessionState, message: ChatMessage) { + if (session.isDeleted) { + return Promise.resolve(); + } + const nextPersistence = session.messagePersistenceTail .catch(() => undefined) .then(() => @@ -2319,6 +2297,10 @@ export class ChatService { skipOfflineNotification?: boolean; }, ) { + if (session.isDeleted) { + return this.createSessionEnvelope(session, message); + } + const envelope = this.createSessionEnvelope(session, message); this.retainEnvelopeForReplay(session, envelope); @@ -2739,7 +2721,7 @@ export class ChatService { } private replaySessionHistory(session: ChatSessionState, lastEventId: number) { - if (!Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) { + if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) { return; } @@ -2751,6 +2733,10 @@ export class ChatService { } private async initializeSession(session: ChatSessionState) { + if (session.isDeleted) { + return; + } + await session.messagePersistenceTail.catch(() => undefined); const messages = await listChatConversationMessages(session.sessionId, { limit: 500 }); @@ -2782,6 +2768,12 @@ export class ChatService { private async handleConnection(socket: WebSocket, request: IncomingMessage) { const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const url = new URL(request.url ?? '/', origin); + + if (!hasAuthorizedChatSocketAccess(request, url)) { + closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized'); + return; + } + const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId(); const clientId = url.searchParams.get('clientId')?.trim() || null; const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0); @@ -2819,6 +2811,62 @@ export class ChatService { this.replaySessionHistory(session, lastEventId); } + async forgetSession(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + const session = this.sessions.get(normalizedSessionId); + const runtimeSnapshot = chatRuntimeService.getSnapshot(); + const runtimeRequestIds = new Set( + [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent] + .filter((item) => item.sessionId === normalizedSessionId) + .map((item) => item.requestId), + ); + + if (session) { + session.isDeleted = true; + session.queue = []; + session.eventHistory = []; + session.pendingQueueReleaseEventId = null; + session.watchedRuntimeRequestId = null; + session.activeRequestCount = 0; + + if (session.socket) { + this.clientStates.delete(session.socket); + closeSocketSafely(this.logger, session.socket, 'failed to close deleted chat websocket session'); + session.socket = null; + } + + this.sessions.delete(normalizedSessionId); + } + + for (const requestId of runtimeRequestIds) { + const detail = chatRuntimeService.getJobDetail(requestId); + + if (detail.availableActions.cancel) { + try { + await this.cancelRuntimeJob(requestId); + } catch { + // ignore and hard-clear runtime state below + } + } else if (detail.availableActions.remove) { + try { + await this.removeQueuedRuntimeJob(requestId); + } catch { + // ignore and hard-clear runtime state below + } + } + + activeChatProcessRegistry.delete(requestId); + this.cancelledRequestIds.delete(requestId); + } + + chatRuntimeService.clearSession(normalizedSessionId); + } + private handleMessage(socket: WebSocket, raw: RawData) { try { const message = JSON.parse(raw.toString()) as ChatInboundMessage; 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 d33db28..2d8f7c6 100755 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -1,5 +1,6 @@ import { execFile, spawn } from 'node:child_process'; import fs from 'node:fs'; +import http from 'node:http'; import { readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -118,6 +119,259 @@ const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000; const DEFERRED_RESTART_DELAY_MS = 2_000; const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500; const DEFERRED_RESTART_POLL_INTERVAL_MS = 150; +const APP_SOURCE_TARGET_PATHS = [ + 'src', + 'public', + 'index.html', + 'package.json', + 'tsconfig.json', + 'tsconfig.app.json', + 'vite.config.ts', + 'scripts', +] as const; +const APP_BUILD_INFO_FILE_CANDIDATES = [ + '/tmp/ai-code-test-app-dist/index.html', + '/tmp/ai-code-test-app-dist/manifest.webmanifest', + '/tmp/ai-code-test-app-dist/assets', +] as const; + +async function readLocalBuildTimestamp(targetPath: string) { + try { + const targetStat = await stat(targetPath); + return normalizeDateTimeValue(targetStat.mtime.toISOString()); + } catch { + return null; + } +} + +async function readContainerBuildTimestamp(definition: ServerDefinition, targetPath: string) { + try { + const { stdout } = await execFileAsync( + 'docker', + ['exec', definition.containerName, 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`], + { + cwd: definition.commandWorkingDirectory, + timeout: 8000, + maxBuffer: 1024 * 1024, + }, + ); + + return normalizeDateTimeValue(stdout.trim()); + } catch (error) { + if (!shouldRetryWithDockerSocket(error)) { + return null; + } + + return readContainerBuildTimestampViaSocket(definition, targetPath); + } +} + +type SourceChangeInfo = { + changedAt: string; + path: string; +}; + +async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise { + try { + const targetStat = await stat(targetPath); + + if (targetStat.isFile()) { + return { + changedAt: normalizeDateTimeValue(targetStat.mtime.toISOString()) ?? targetStat.mtime.toISOString(), + path: path.relative(rootPath, targetPath) || path.basename(targetPath), + }; + } + + if (!targetStat.isDirectory()) { + return null; + } + + const entries = await fs.promises.readdir(targetPath, { withFileTypes: true }); + let latest: SourceChangeInfo | null = null; + + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === '.docker') { + continue; + } + + const childPath = path.join(targetPath, entry.name); + const candidate = await findLatestSourceChangeInPath(rootPath, childPath); + + if (!candidate) { + continue; + } + + if (!latest || candidate.changedAt > latest.changedAt) { + latest = candidate; + } + } + + return latest; + } catch { + return null; + } +} + +async function readLatestAppSourceChange() { + const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT); + let latest: SourceChangeInfo | null = null; + + for (const relativePath of APP_SOURCE_TARGET_PATHS) { + const candidate = await findLatestSourceChangeInPath(projectRoot, path.join(projectRoot, relativePath)); + + if (!candidate) { + continue; + } + + if (!latest || candidate.changedAt > latest.changedAt) { + latest = candidate; + } + } + + return latest; +} + +async function requestDockerEngine( + method: string, + requestPath: string, + payload?: unknown, +): Promise { + const socketPath = resolveDockerSocketPath(process.env); + const body = payload == null ? null : JSON.stringify(payload); + + return await new Promise((resolve, reject) => { + const request = http.request( + { + socketPath, + path: requestPath, + method, + headers: body + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + } + : undefined, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + const responseText = Buffer.concat(chunks).toString('utf8'); + + if ((response.statusCode ?? 500) >= 400) { + reject( + new Error( + trimPreview(`Docker API ${method} ${requestPath} failed: ${response.statusCode} ${responseText}`, 400) ?? + `Docker API ${method} ${requestPath} failed: ${response.statusCode}`, + ), + ); + return; + } + + if (!responseText.trim()) { + resolve(undefined as T); + return; + } + + try { + resolve(JSON.parse(responseText) as T); + } catch (error) { + reject(error); + } + }); + }, + ); + + request.once('error', reject); + request.setTimeout(8000, () => { + request.destroy(new Error(`Docker API timeout: ${method} ${requestPath}`)); + }); + + if (body) { + request.write(body); + } + + request.end(); + }); +} + +type DockerContainerInspect = { + Id?: string; + Name?: string; + State?: { + StartedAt?: string; + Status?: string; + }; +}; + +async function inspectContainerViaSocket(containerName: string) { + return requestDockerEngine('GET', `/containers/${encodeURIComponent(containerName)}/json`); +} + +async function execContainerCommandViaSocket(containerName: string, command: string[]) { + const execCreated = await requestDockerEngine<{ Id?: string }>('POST', `/containers/${encodeURIComponent(containerName)}/exec`, { + AttachStdout: true, + AttachStderr: true, + Cmd: command, + }); + const execId = execCreated.Id?.trim(); + + if (!execId) { + return null; + } + + const output = await new Promise((resolve, reject) => { + const request = http.request( + { + socketPath: resolveDockerSocketPath(process.env), + path: `/exec/${encodeURIComponent(execId)}/start`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + resolve(decodeDockerExecStream(Buffer.concat(chunks))); + }); + }, + ); + + request.once('error', reject); + request.setTimeout(8000, () => { + request.destroy(new Error(`Docker exec timeout: ${containerName}`)); + }); + request.write(JSON.stringify({ Detach: false, Tty: false })); + request.end(); + }); + + const execState = await requestDockerEngine<{ ExitCode?: number | null }>('GET', `/exec/${encodeURIComponent(execId)}/json`); + if ((execState.ExitCode ?? 1) !== 0) { + return null; + } + + return output.trim(); +} + +async function readContainerBuildTimestampViaSocket(definition: ServerDefinition, targetPath: string) { + try { + const output = await execContainerCommandViaSocket(definition.containerName, [ + 'sh', + '-lc', + `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`, + ]); + + return normalizeDateTimeValue(output); + } catch { + return null; + } +} function normalizeUrl(value: string) { return value.trim().replace(/\/+$/, ''); @@ -181,7 +435,27 @@ function shouldRetryWithDockerSocket(error: unknown) { const failure = error instanceof Error ? (error as ExecFileFailure) : null; const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'); - return failure?.code === 127 || /docker CLI not found/i.test(detail); + return failure?.code === 127 || failure?.code === 'ENOENT' || /docker CLI not found|spawn docker ENOENT/i.test(detail); +} + +function decodeDockerExecStream(buffer: Buffer) { + let offset = 0; + const chunks: Buffer[] = []; + + while (offset + 8 <= buffer.length) { + const frameLength = buffer.readUInt32BE(offset + 4); + const frameStart = offset + 8; + const frameEnd = frameStart + frameLength; + + if (frameEnd > buffer.length) { + break; + } + + chunks.push(buffer.subarray(frameStart, frameEnd)); + offset = frameEnd; + } + + return (chunks.length > 0 ? Buffer.concat(chunks) : buffer).toString('utf8'); } export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) { @@ -794,6 +1068,19 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise { + if (definition.key !== 'test') { + return null; + } + + const latestSourceChange = await readLatestAppSourceChange(); + const latestSourceChangedAt = latestSourceChange?.changedAt ?? null; + + for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) { + const builtAt = + (await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath)); + + if (!builtAt) { + continue; + } + + return { + runningVersion: null, + runningBuiltAt: builtAt, + latestVersion: null, + latestBuiltAt: builtAt, + latestSourceChangeAt: latestSourceChangedAt, + latestSourceChangePath: latestSourceChange?.path ?? null, + buildRequired: Boolean(latestSourceChangedAt && latestSourceChangedAt > builtAt), + updateAvailable: false, + updateSummary: + latestSourceChangedAt && latestSourceChangedAt > builtAt + ? `수정된 소스가 테스트 빌드보다 새롭습니다.${latestSourceChange?.path ? ` (${latestSourceChange.path})` : ''} 테스트 앱을 다시 빌드해야 합니다.` + : `테스트 빌드 기준: ${builtAt}`, + }; + } + + return { + runningVersion: null, + runningBuiltAt: null, + latestVersion: null, + latestBuiltAt: null, + latestSourceChangeAt: latestSourceChangedAt, + latestSourceChangePath: latestSourceChange?.path ?? null, + buildRequired: Boolean(latestSourceChangedAt), + updateAvailable: false, + updateSummary: latestSourceChangedAt + ? `테스트 빌드 시각을 읽지 못했습니다.${latestSourceChange?.path ? ` 최근 소스 변경: ${latestSourceChange.path}` : ''}` + : '테스트 빌드 시각을 읽지 못했습니다.', + }; +} + async function inspectBuild(definition: ServerDefinition): Promise { if (definition.key !== 'work-server') { + const appBuildInfo = await inspectAppContainerBuild(definition); + + if (appBuildInfo) { + return appBuildInfo; + } + return { runningVersion: null, runningBuiltAt: null, diff --git a/index.html b/index.html index b955610..803fd91 100755 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> + + AI Code App diff --git a/package.json b/package.json index c5d5049..b0e6767 100755 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "plan:codex:once": "node scripts/run-plan-codex-once.mjs", "server-command:runner": "node scripts/run-server-command-runner.mjs", "build:app": "tsc -b && vite build --outDir app-dist", - "build:test-app": "VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", + "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", "build:lib": "tsc -p tsconfig.lib.json", "build": "npm run build:lib && npm run build:app", "prepublishOnly": "npm run build:lib", diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 82578e8..be49ef0 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -663,14 +663,10 @@ async function validateCodexExecutionRuntime(repoPath, codexBin) { async function runCodexLiveExecution(payload, response) { const requestId = String(payload?.requestId ?? '').trim(); const sessionId = String(payload?.sessionId ?? '').trim(); - const repoPath = translateWorkspacePathToHost(String(payload?.repoPath ?? '').trim() || projectRoot); + const repoPath = projectRoot; const prompt = String(payload?.prompt ?? ''); - const resourceDir = translateWorkspacePathToHost( - String(payload?.resourceDir ?? path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource')), - ); - const uploadDir = translateWorkspacePathToHost( - String(payload?.uploadDir ?? path.join(resourceDir, 'uploads')), - ); + const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'); + const uploadDir = path.join(resourceDir, 'uploads'); const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; if (!requestId || !sessionId || !prompt.trim()) { diff --git a/src/app/main/ChatNotificationBridge.tsx b/src/app/main/ChatNotificationBridge.tsx deleted file mode 100644 index ec451d6..0000000 --- a/src/app/main/ChatNotificationBridge.tsx +++ /dev/null @@ -1,447 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useAppStore } from '../../store'; -import { chatConnectionGateway, chatGateway } from './chatV2'; -import { - createNotificationMessage, - sendClientNotification, - shouldFallbackToLocalNotification, - showLocalClientNotification, -} from './notificationApi'; -import { - getChatClientSessionId, - loadStoredChatMessages, - persistStoredChatMessages, -} from './mainChatPanel'; -import type { ChatConversationSummary, ChatJobEvent, ChatMessage, ChatViewContext } from './mainChatPanel/types'; - -const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000; - -function isStandaloneDisplayMode() { - if (typeof window === 'undefined') { - return false; - } - - return ( - window.matchMedia?.('(display-mode: standalone)').matches === true || - (window.navigator as Navigator & { standalone?: boolean }).standalone === true - ); -} - -function createConversationPreviewText(text: string) { - const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); - return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; -} - -function createChatNotificationBody(text: string, fallback: string) { - const preview = createConversationPreviewText(text); - return preview || fallback; -} - -function createChatQuestionAnswerNotificationBody(args: { - questionText?: string | null; - answerText?: string | null; - fallback: string; -}) { - const questionPreview = createConversationPreviewText(args.questionText ?? ''); - const answerPreview = createConversationPreviewText(args.answerText ?? ''); - - if (questionPreview && answerPreview) { - return `질문: ${questionPreview}\n답변: ${answerPreview}`; - } - - if (answerPreview) { - return `답변: ${answerPreview}`; - } - - if (questionPreview) { - return `질문: ${questionPreview}`; - } - - return args.fallback; -} - -function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { - const questionPreview = createConversationPreviewText(questionText ?? ''); - return questionPreview ? `질문: ${questionPreview}` : fallback ?? ''; -} - -function normalizeNotificationDetailText(text?: string | null) { - const normalized = String(text ?? '').trim(); - return normalized || undefined; -} - -function buildChatNotificationLink(sessionId: string) { - const normalizedSessionId = sessionId.trim(); - - if (!normalizedSessionId || typeof window === 'undefined') { - return ''; - } - - return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`; -} - -async function tryShowLocalChatNotification(args: { - title: string; - body: string; - threadId: string; - data: Record; -}) { - await showLocalClientNotification({ - title: args.title, - body: args.body, - threadId: args.threadId, - data: args.data, - }).catch(() => false); -} - -function findQuestionText(messages: ChatMessage[], requestId?: string | null) { - if (!requestId) { - return ''; - } - - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]; - - if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) { - continue; - } - - return candidate.text; - } - - return ''; -} - -function findLatestCodexMessage(messages: ChatMessage[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]; - - if (candidate.author === 'codex') { - return candidate; - } - } - - return null; -} - -export function ChatNotificationBridge() { - const { currentPage, focusedComponentId } = useAppStore(); - const [sessionId] = useState(() => getChatClientSessionId()); - const [messages, setMessages] = useState(() => loadStoredChatMessages(getChatClientSessionId())); - const [conversation, setConversation] = useState(null); - const notifiedIncomingMessageKeysRef = useRef([]); - const notifiedFailedJobKeysRef = useRef([]); - const lastPolledCodexMessageIdBySessionRef = useRef>({}); - const conversationPollInFlightRef = useRef(false); - const messagesRef = useRef(messages); - const currentContext: ChatViewContext = useMemo( - () => ({ - 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: null, - chatTypeLabel: '', - chatTypeDescription: '', - chatTypeIsTemplate: false, - }), - [currentPage, focusedComponentId], - ); - - useEffect(() => { - messagesRef.current = messages; - persistStoredChatMessages(sessionId, messages); - }, [messages, sessionId]); - - const createChatNotification = ({ - targetSessionId, - conversationTitle, - title, - body, - previewText, - priority, - metadata, - }: { - targetSessionId: string; - conversationTitle?: string | null; - title: string; - body: string; - previewText?: string; - priority: 'normal' | 'high'; - metadata?: Record; - }) => { - const resolvedConversationTitle = conversationTitle || '현재 채팅방'; - const linkUrl = buildChatNotificationLink(targetSessionId); - const notificationData = { - category: 'chat', - priority, - sessionId: targetSessionId, - conversationTitle: resolvedConversationTitle, - targetUrl: linkUrl, - linkUrl, - ...metadata, - }; - const serializedNotificationData = Object.fromEntries( - Object.entries(notificationData).flatMap(([key, value]) => { - if (value == null) { - return []; - } - - return [[key, String(value)]]; - }), - ); - const pushPayload = { - title, - body, - threadId: `chat:${targetSessionId}`, - data: serializedNotificationData, - }; - - return Promise.allSettled([ - createNotificationMessage({ - title, - body, - category: 'chat', - source: 'codex-live', - priority, - metadata: { - ...notificationData, - previewText, - linkLabel: '채팅 바로 열기', - }, - }), - sendClientNotification(pushPayload), - ]).then(async ([storedResult, pushResult]) => { - if (pushResult.status === 'rejected') { - await tryShowLocalChatNotification(pushPayload); - } else if (shouldFallbackToLocalNotification(pushResult.value)) { - await tryShowLocalChatNotification(pushPayload); - } - - if (storedResult.status === 'fulfilled') { - return storedResult.value; - } - - if (pushResult.status === 'fulfilled') { - return pushResult.value; - } - - throw storedResult.reason; - }).catch(() => undefined); - }; - - const handleIncomingMessageEvent = (incomingMessage: ChatMessage) => { - if (incomingMessage.author !== 'codex' || conversation?.notifyOffline !== true) { - return; - } - - const notificationKey = `${sessionId}:${incomingMessage.id}:${incomingMessage.timestamp}`; - - if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) { - return; - } - - notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120); - - const questionText = findQuestionText(messagesRef.current, incomingMessage.clientRequestId); - - void createChatNotification({ - targetSessionId: sessionId, - conversationTitle: conversation?.title, - title: 'Codex Live 새 메시지', - body: createChatQuestionAnswerNotificationBody({ - questionText, - answerText: incomingMessage.text, - fallback: createChatNotificationBody( - incomingMessage.text, - `${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`, - ), - }), - previewText: createChatQuestionOnlyNotificationPreview( - questionText, - createChatNotificationBody( - incomingMessage.text, - `${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`, - ), - ), - priority: 'normal', - metadata: { - messageId: incomingMessage.id, - messageTimestamp: incomingMessage.timestamp, - questionText: normalizeNotificationDetailText(questionText), - answerText: normalizeNotificationDetailText(incomingMessage.text), - }, - }); - }; - - const handleJobEvent = (event: ChatJobEvent) => { - if (event.status !== 'failed' || conversation?.notifyOffline !== true) { - return; - } - - const notificationKey = `${sessionId}:${event.requestId}:${event.status}`; - - if (notifiedFailedJobKeysRef.current.includes(notificationKey)) { - return; - } - - notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80); - - const questionText = findQuestionText(messagesRef.current, event.requestId); - - void createChatNotification({ - targetSessionId: sessionId, - conversationTitle: conversation?.title, - title: 'Codex Live 요청 실패', - body: createChatQuestionAnswerNotificationBody({ - questionText, - answerText: event.message, - fallback: `${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`, - }), - previewText: createChatQuestionOnlyNotificationPreview( - questionText, - `${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`, - ), - priority: 'high', - metadata: { - requestId: event.requestId, - status: event.status, - questionText: normalizeNotificationDetailText(questionText), - answerText: normalizeNotificationDetailText(event.message), - }, - }); - }; - - useEffect(() => { - let cancelled = false; - - const pollConversations = async (seedOnly: boolean) => { - if (conversationPollInFlightRef.current) { - return; - } - - conversationPollInFlightRef.current = true; - - try { - const items = await chatGateway.listConversations(); - - if (cancelled) { - return; - } - - setConversation(items.find((item) => item.sessionId === sessionId) ?? null); - - const enabledItems = items.filter((item) => item.notifyOffline === true); - - if (enabledItems.length === 0) { - return; - } - - const detailResults = await Promise.allSettled( - enabledItems.map(async (item) => ({ - item, - detail: await chatGateway.getConversationDetail(item.sessionId), - })), - ); - - for (const result of detailResults) { - if (cancelled || result.status !== 'fulfilled') { - continue; - } - - const { item, detail } = result.value; - const latestCodexMessage = findLatestCodexMessage(detail.messages); - - if (!latestCodexMessage) { - continue; - } - - const previousMessageId = lastPolledCodexMessageIdBySessionRef.current[item.sessionId]; - lastPolledCodexMessageIdBySessionRef.current[item.sessionId] = latestCodexMessage.id; - - if (seedOnly || previousMessageId == null || latestCodexMessage.id <= previousMessageId) { - continue; - } - - const notificationKey = `${item.sessionId}:${latestCodexMessage.id}:${latestCodexMessage.timestamp}`; - - if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) { - continue; - } - - notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120); - - const questionText = findQuestionText(detail.messages, latestCodexMessage.clientRequestId); - - void createChatNotification({ - targetSessionId: item.sessionId, - conversationTitle: item.title, - title: 'Codex Live 새 메시지', - body: createChatQuestionAnswerNotificationBody({ - questionText, - answerText: latestCodexMessage.text, - fallback: createChatNotificationBody( - latestCodexMessage.text, - `${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`, - ), - }), - previewText: createChatQuestionOnlyNotificationPreview( - questionText, - createChatNotificationBody( - latestCodexMessage.text, - `${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`, - ), - ), - priority: 'normal', - metadata: { - messageId: latestCodexMessage.id, - messageTimestamp: latestCodexMessage.timestamp, - questionText: normalizeNotificationDetailText(questionText), - answerText: normalizeNotificationDetailText(latestCodexMessage.text), - }, - }); - } - } catch { - // Ignore polling errors and retry on the next cycle. - } finally { - conversationPollInFlightRef.current = false; - } - }; - - void pollConversations(true); - - const intervalId = window.setInterval(() => { - void pollConversations(false); - }, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS); - - const handleResume = () => { - void pollConversations(false); - }; - - window.addEventListener('focus', handleResume); - window.addEventListener('pageshow', handleResume); - document.addEventListener('visibilitychange', handleResume); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - window.removeEventListener('focus', handleResume); - window.removeEventListener('pageshow', handleResume); - document.removeEventListener('visibilitychange', handleResume); - }; - }, [sessionId]); - - chatConnectionGateway.useConnection({ - sessionId, - currentContext, - setMessages, - onMessageEvent: handleIncomingMessageEvent, - onJobEvent: handleJobEvent, - }); - - return null; -} diff --git a/src/app/main/ChatNotificationBridgeV2.tsx b/src/app/main/ChatNotificationBridgeV2.tsx index 38dc248..12bf8b4 100644 --- a/src/app/main/ChatNotificationBridgeV2.tsx +++ b/src/app/main/ChatNotificationBridgeV2.tsx @@ -57,7 +57,10 @@ function buildChatNotificationLink(sessionId: string) { return ''; } - return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`; + const targetUrl = new URL('/chat/live', window.location.origin); + targetUrl.searchParams.set('topMenu', 'chat'); + targetUrl.searchParams.set('sessionId', normalizedSessionId); + return targetUrl.toString(); } async function tryShowLocalChatNotification(args: { diff --git a/src/app/main/ChatRuntimeBridge.tsx b/src/app/main/ChatRuntimeBridge.tsx deleted file mode 100644 index 9bef611..0000000 --- a/src/app/main/ChatRuntimeBridge.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useAppStore } from '../../store'; -import { - fetchChatRuntimeSnapshot, - getChatClientSessionId, - setSharedChatRuntimeSnapshot, - useChatConnection, -} from './mainChatPanel'; -import type { ChatMessage, ChatViewContext } from './mainChatPanel/types'; - -function isStandaloneDisplayMode() { - if (typeof window === 'undefined') { - return false; - } - - return ( - window.matchMedia?.('(display-mode: standalone)').matches === true || - (window.navigator as Navigator & { standalone?: boolean }).standalone === true - ); -} - -export function ChatRuntimeBridge() { - const { currentPage, focusedComponentId } = useAppStore(); - const [sessionId] = useState(() => getChatClientSessionId()); - const [, setMessages] = useState([]); - - const currentContext: ChatViewContext = useMemo( - () => ({ - 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: null, - chatTypeLabel: '', - chatTypeDescription: '', - chatTypeIsTemplate: false, - }), - [currentPage, focusedComponentId], - ); - - useEffect(() => { - let cancelled = false; - - void fetchChatRuntimeSnapshot() - .then((snapshot) => { - if (!cancelled) { - setSharedChatRuntimeSnapshot(snapshot); - } - }) - .catch(() => undefined); - - return () => { - cancelled = true; - }; - }, []); - - useChatConnection({ - sessionId, - currentContext, - setMessages, - }); - - return null; -} diff --git a/src/app/main/ChatSourceChangesPage.tsx b/src/app/main/ChatSourceChangesPage.tsx index 7a9643f..c73c94a 100644 --- a/src/app/main/ChatSourceChangesPage.tsx +++ b/src/app/main/ChatSourceChangesPage.tsx @@ -1,9 +1,9 @@ import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd'; import { useEffect, useMemo, useState } from 'react'; -import { fetchPlanActionHistories, fetchPlanItems } from '../../features/planBoard/api'; +import { fetchServerCommands } from '../../features/serverCommand/api'; +import type { ServerCommandItem } from '../../features/serverCommand/types'; import { chatGateway } from './chatV2'; -import type { ChatMessage } from './mainChatPanel/types'; -import type { ChatConversationRequest } from './mainChatPanel/types'; +import type { ChatMessage, ChatConversationRequest } from './mainChatPanel/types'; const { Paragraph, Text, Title } = Typography; @@ -11,6 +11,8 @@ type ChatSourceChangeEntry = { id: string; sessionId: string; conversationTitle: string; + chatTypeId: string | null; + chatTypeLabel: string; requestId: string; requestTitle: string; questionText: string; @@ -148,6 +150,12 @@ function extractCurrentSourceFiles(text: string) { ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), ...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), + ...(text.match(/\[[^\]]*]\((\/api\/chat\/resources\/[^)\s]+)\)/g) ?? []) + .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), + ...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []), + ...(text.match(/\[[^\]]*]\((\/?(?:public\/)?\.codex_chat\/[^)\s]+\/resource\/[^)\s]+)\)/g) ?? []) + .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), + ...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []), ] .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); @@ -160,22 +168,12 @@ function extractCurrentSourceFiles(text: string) { } function extractChangedFiles(text: string) { - const diffPathMatches = Array.from( + const matches = Array.from( text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), ) .flatMap((match) => [match[1], match[2], match[3]]) .filter((value): value is string => Boolean(value)); - const matches = [ - ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) - .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), - ...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), - ...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []), - ...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []), - ...(text.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []), - ...diffPathMatches, - ]; - return Array.from( new Set( matches @@ -248,29 +246,20 @@ function appendUniqueText(target: string[], value: string | null | undefined) { target.push(normalized); } -function resolveDeploymentStatus(updatedAt: string, latestReleaseCompletedAt: string | null): DeploymentFilterValue { - if (!latestReleaseCompletedAt) { +function resolveDeploymentStatus( + updatedAt: string, + latestTestServerBuiltAt: string | null, + testServerCommand: Pick | null, +): DeploymentFilterValue { + if (testServerCommand && !testServerCommand.buildRequired && !testServerCommand.updateAvailable) { + return 'deployed'; + } + + if (!latestTestServerBuiltAt) { return 'pre-deploy'; } - return getTimeValue(updatedAt) > getTimeValue(latestReleaseCompletedAt) ? 'pre-deploy' : 'deployed'; -} - -function isTestReleaseTarget(value: string | null | undefined) { - const normalized = String(value ?? '').trim().toLowerCase(); - - if (!normalized) { - return false; - } - - return ( - normalized === 'test' || - normalized.includes('test.sm-home.cloud') || - normalized.includes('/test') || - normalized.startsWith('test-') || - normalized.endsWith('-test') || - normalized.includes(' test ') - ); + return getTimeValue(updatedAt) > getTimeValue(latestTestServerBuiltAt) ? 'pre-deploy' : 'deployed'; } function resolveSourceChangedAt( @@ -446,10 +435,15 @@ function resolveRequestQuestion(request: ChatConversationRequest, messages: Chat function buildSourceChangeEntry( conversationTitle: string, sessionId: string, + conversation: { + chatTypeId?: string | null; + contextLabel?: string | null; + }, request: ChatConversationRequest, messages: ChatMessage[], nextRequest: ChatConversationRequest | undefined, - latestReleaseCompletedAt: string | null, + testServerCommand: Pick | null, + latestTestServerBuiltAt: string | null, ) { const questionText = resolveRequestQuestion(request, messages); const answerText = resolveRequestExplanation(request, messages, nextRequest); @@ -468,6 +462,8 @@ function buildSourceChangeEntry( id: `${sessionId}:${request.requestId}`, sessionId, conversationTitle, + chatTypeId: String(conversation.chatTypeId ?? '').trim() || null, + chatTypeLabel: String(conversation.contextLabel ?? '').trim(), requestId: request.requestId, requestTitle: createRequestTitle(questionText, request.requestId), questionText, @@ -480,7 +476,7 @@ function buildSourceChangeEntry( currentSourceFiles, diffBlocks, deploymentStatus: currentSourceFiles.length > 0 - ? resolveDeploymentStatus(sourceChangedAt, latestReleaseCompletedAt) + ? resolveDeploymentStatus(sourceChangedAt, latestTestServerBuiltAt, testServerCommand) : 'pre-deploy', currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied', } satisfies ChatSourceChangeEntry; @@ -490,9 +486,9 @@ export function ChatSourceChangesPage() { const [entries, setEntries] = useState([]); const [selectedEntryId, setSelectedEntryId] = useState(null); const [searchText, setSearchText] = useState(''); - const [deploymentFilter, setDeploymentFilter] = useState('pre-deploy'); - const [currentSourceFilter, setCurrentSourceFilter] = useState('all'); - const [latestReleaseCompletedAt, setLatestReleaseCompletedAt] = useState(null); + const [deploymentSearchCondition, setDeploymentSearchCondition] = useState('pre-deploy'); + const [currentSourceSearchCondition, setCurrentSourceSearchCondition] = useState('applied'); + const [latestTestServerBuiltAt, setLatestTestServerBuiltAt] = useState(null); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -504,19 +500,9 @@ export function ChatSourceChangesPage() { setErrorMessage(null); try { - const planItems = await fetchPlanItems('all'); - const testPlanItems = planItems.filter((item) => isTestReleaseTarget(item.releaseTarget)); - const actionResults = await Promise.allSettled(planItems.map((item) => fetchPlanActionHistories(item.id))); - const nextLatestReleaseCompletedAt = - actionResults - .flatMap((result, index) => - result.status === 'fulfilled' && testPlanItems.some((item) => item.id === planItems[index]?.id) - ? result.value - : [], - ) - .filter((history) => history.actionType === 'release반영완료') - .map((history) => history.createdAt) - .sort((left, right) => getTimeValue(right) - getTimeValue(left))[0] ?? null; + const serverCommands = await fetchServerCommands(); + const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null; + const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null; const conversations = await chatGateway.listConversations(); const details = await Promise.allSettled( conversations.map(async (conversation) => ({ @@ -541,17 +527,19 @@ export function ChatSourceChangesPage() { buildSourceChangeEntry( conversation.title || '새 대화', conversation.sessionId, + detail.item, request, detail.messages, requests[index + 1], - nextLatestReleaseCompletedAt, + testServerCommand, + nextLatestTestServerBuiltAt, ), ) .filter((item): item is ChatSourceChangeEntry => Boolean(item)); }) .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()); - setLatestReleaseCompletedAt(nextLatestReleaseCompletedAt); + setLatestTestServerBuiltAt(nextLatestTestServerBuiltAt); setEntries(nextEntries); setSelectedEntryId((previous) => { if (previous && nextEntries.some((entry) => entry.id === previous)) { @@ -582,11 +570,11 @@ export function ChatSourceChangesPage() { const keyword = searchText.trim().toLowerCase(); return entries.filter((entry) => { - if (deploymentFilter !== 'all' && entry.deploymentStatus !== deploymentFilter) { + if (deploymentSearchCondition !== 'all' && entry.deploymentStatus !== deploymentSearchCondition) { return false; } - if (currentSourceFilter !== 'all' && entry.currentSourceStatus !== currentSourceFilter) { + if (currentSourceSearchCondition !== 'all' && entry.currentSourceStatus !== currentSourceSearchCondition) { return false; } @@ -607,7 +595,7 @@ export function ChatSourceChangesPage() { .toLowerCase() .includes(keyword); }); - }, [currentSourceFilter, deploymentFilter, entries, searchText]); + }, [currentSourceSearchCondition, deploymentSearchCondition, entries, searchText]); const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null; @@ -619,7 +607,7 @@ export function ChatSourceChangesPage() { Codex Live 변경 이력 - 채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 test 도메인 배포 반영 여부 기준으로 확인합니다. + 채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 현재 소스 반영 여부와 test 도메인 배포 상태를 검색조건으로 확인합니다. + 검색조건 { - setCurrentSourceFilter(values); + onChange={(value) => { + setCurrentSourceSearchCondition(value); }} /> - {latestReleaseCompletedAt - ? `최근 test 도메인 배포 완료 시각 기준: ${formatDateTime(latestReleaseCompletedAt)}` - : '기록된 test 도메인 배포 완료 이력이 없어 현재 항목은 모두 배포 전으로 표시됩니다.'} + {latestTestServerBuiltAt + ? `현재 TEST 서버 빌드 시각 기준: ${formatDateTime(latestTestServerBuiltAt)}` + : 'TEST 서버 빌드 시각을 읽지 못해 현재 항목은 모두 배포 전으로 표시됩니다.'} @@ -668,7 +657,7 @@ export function ChatSourceChangesPage() { description={ entries.length === 0 ? '소스 수정 흔적이 있는 Codex Live 요청이 아직 없습니다.' - : '현재 검색어 또는 필터에 맞는 변경 이력이 없습니다.' + : '현재 검색어 또는 검색조건에 맞는 변경 이력이 없습니다.' } /> diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 3b74367..574fbb0 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { canUseChatType, CHAT_PERMISSION_ROLE_LABELS, + deleteChatType, resolveCurrentChatPermissionRoles, upsertChatType, useChatTypeRegistry, @@ -49,10 +50,12 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { export function ChatTypeManagementPage() { const { hasAccess } = useTokenAccess(); - const { chatTypes, setChatTypes } = useChatTypeRegistry(); + const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry(); const [selectedChatTypeId, setSelectedChatTypeId] = useState(chatTypes[0]?.id ?? null); const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); const [isCreating, setIsCreating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [form] = Form.useForm(); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); @@ -104,7 +107,7 @@ export function ChatTypeManagementPage() { setDetailMode('list'); }; - const handleDelete = () => { + const handleDelete = async () => { if (!selectedChatType) { return; } @@ -113,13 +116,22 @@ export function ChatTypeManagementPage() { return; } - const nextChatTypes = chatTypes.filter((item) => item.id !== selectedChatType.id); - setChatTypes(nextChatTypes); - setSelectedChatTypeId(nextChatTypes[0]?.id ?? null); - setIsCreating(false); - setDetailMode('list'); - form.resetFields(); - form.setFieldsValue(EMPTY_FORM_VALUE); + const nextChatTypes = deleteChatType(chatTypes, selectedChatType.id); + setIsSaving(true); + setSaveErrorMessage(''); + + try { + const savedChatTypes = await setChatTypes(nextChatTypes); + setSelectedChatTypeId(savedChatTypes[0]?.id ?? null); + setIsCreating(false); + setDetailMode('list'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + } catch (error) { + setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 삭제에 실패했습니다.'); + } finally { + setIsSaving(false); + } }; if (!hasAccess) { @@ -148,9 +160,11 @@ export function ChatTypeManagementPage() { } >
+ {errorMessage ? : null} + {saveErrorMessage ? : null}
등록 컨텍스트 - {chatTypes.length}건 + {isLoading ? '불러오는 중' : `${chatTypes.length}건`}
{chatTypes.length > 0 ? ( @@ -170,13 +184,14 @@ export function ChatTypeManagementPage() { openDetail(item.id); }} actions={[ - ) : null} @@ -226,6 +241,8 @@ export function ChatTypeManagementPage() { } >
+ {errorMessage ? : null} + {saveErrorMessage ? : null}
{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'} 이름과 기본 문맥 설명, 권한 대상만 관리하면 채팅에 그대로 반영됩니다. @@ -235,14 +252,22 @@ export function ChatTypeManagementPage() { layout="vertical" form={form} initialValues={EMPTY_FORM_VALUE} - onFinish={(values) => { + onFinish={async (values) => { const nextChatTypes = upsertChatType(chatTypes, values); - setChatTypes(nextChatTypes); + setIsSaving(true); + setSaveErrorMessage(''); - const savedChatType = nextChatTypes.find((item) => item.id === values.id || item.name === values.name); - setIsCreating(false); - setSelectedChatTypeId(savedChatType?.id ?? null); - setDetailMode('detail'); + try { + const savedChatTypes = await setChatTypes(nextChatTypes); + const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name); + setIsCreating(false); + setSelectedChatTypeId(savedChatType?.id ?? null); + setDetailMode('detail'); + } catch (error) { + setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.'); + } finally { + setIsSaving(false); + } }} > - - +
diff --git a/src/app/main/HeaderMessageCenter.css b/src/app/main/HeaderMessageCenter.css index 7b71d8b..5632722 100644 --- a/src/app/main/HeaderMessageCenter.css +++ b/src/app/main/HeaderMessageCenter.css @@ -10,6 +10,10 @@ gap: 2px; } +.header-message-center__tabs { + width: 100%; +} + .header-message-center__loading { display: flex; align-items: center; diff --git a/src/app/main/HeaderMessageCenter.tsx b/src/app/main/HeaderMessageCenter.tsx index c5c8021..69fe8b7 100644 --- a/src/app/main/HeaderMessageCenter.tsx +++ b/src/app/main/HeaderMessageCenter.tsx @@ -1,9 +1,10 @@ import { BellOutlined, ReloadOutlined } from '@ant-design/icons'; -import { Alert, Badge, Button, Drawer, Empty, List, Modal, Space, Spin, Tag, Typography } from 'antd'; +import { Alert, Badge, Button, Drawer, Empty, List, Modal, Segmented, Space, Spin, Tag, Typography } from 'antd'; import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { type NotificationMessageItem, + type NotificationMessageListStatus, type NotificationMessagePriority, } from './notificationApi'; import { useNotificationController } from './chatV2/hooks/useNotificationController'; @@ -142,6 +143,8 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo const [draggingMessageId, setDraggingMessageId] = useState(null); const [dragOffsetX, setDragOffsetX] = useState(0); const { + listStatus, + setListStatus, unreadCount, detailOpen, setDetailOpen, @@ -159,6 +162,11 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo handleDeleteMessage: deleteMessage, } = useNotificationController(drawerOpen); + const listStatusOptions: Array<{ value: NotificationMessageListStatus; label: string }> = [ + { value: 'unread', label: `안읽음 ${unreadCount}` }, + { value: 'all', label: '전체' }, + ]; + const resetSwipeState = () => { swipeStartXRef.current = null; swipeStartYRef.current = null; @@ -333,10 +341,20 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo >
- 미확인 메시지 {unreadCount}건 + {listStatus === 'unread' ? `안읽음 메시지 ${unreadCount}건` : '전체 알림 목록'} 최신순으로 최대 30건까지 표시합니다.
+ { + setListStatus(value as NotificationMessageListStatus); + }} + /> + {listError ? : null} {listLoading ? ( diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index 28bcb2e..3c2d317 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -30,6 +30,10 @@ .app-chat-panel__preview-modal.ant-modal { z-index: 1400; + max-width: 100vw; + margin: 0; + top: 0; + padding-bottom: 0; } .app-chat-panel__preview-modal.ant-modal .ant-modal-mask { @@ -99,6 +103,15 @@ height: 100%; } +.app-chat-panel__stack--chat { + flex-direction: row; + align-items: stretch; + flex: 1 1 auto; + gap: 0; + width: 100%; + min-width: 0; +} + .app-chat-panel__conversation-shell { position: relative; display: flex; @@ -116,8 +129,10 @@ .app-chat-panel__conversation-list { display: flex; flex-direction: column; - width: 220px; - min-width: 220px; + flex: 0 0 280px; + width: 280px; + min-width: 280px; + max-width: 280px; min-height: 0; border-right: 1px solid rgba(148, 163, 184, 0.14); background: rgba(248, 250, 252, 0.72); @@ -136,6 +151,19 @@ padding: 8px 8px 0; } +.app-chat-panel__conversation-list-search .ant-input-affix-wrapper, +.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:hover, +.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus, +.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus-within { + border-color: rgba(148, 163, 184, 0.12); + box-shadow: none; +} + +.app-chat-panel__conversation-list-search .ant-input-affix-wrapper { + border-radius: 999px; + background: rgba(255, 255, 255, 0.9); +} + .app-chat-panel__conversation-list-body { display: flex; flex: 1; @@ -220,7 +248,7 @@ gap: 6px; min-width: 0; padding: 0; - border: 1px solid rgba(148, 163, 184, 0.14); + border: 1px solid rgba(148, 163, 184, 0.08); background: rgba(255, 255, 255, 0.82); border-radius: 14px; transition: @@ -231,13 +259,13 @@ } .app-chat-panel__conversation-item--active { - border-color: rgba(59, 130, 246, 0.35); + border-color: rgba(59, 130, 246, 0.16); background: rgba(239, 246, 255, 0.95); box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08); } .app-chat-panel__conversation-item--processing { - border-color: rgba(245, 158, 11, 0.34); + border-color: rgba(245, 158, 11, 0.14); background: linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%), #fff; @@ -247,7 +275,7 @@ } .app-chat-panel__conversation-item--unread { - border-color: rgba(37, 99, 235, 0.7); + border-color: rgba(37, 99, 235, 0.18); background: linear-gradient(90deg, rgba(147, 197, 253, 0.98), rgba(219, 234, 254, 0.98) 22%, rgba(239, 246, 255, 0.98) 46%, rgba(255, 255, 255, 0.99) 68%), #fff; @@ -257,7 +285,7 @@ } .app-chat-panel__conversation-item--unread-section { - border-color: rgba(37, 99, 235, 0.82); + border-color: rgba(37, 99, 235, 0.2); background: linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%), #fff; @@ -296,7 +324,7 @@ } .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { - border-color: rgba(29, 78, 216, 0.78); + border-color: rgba(29, 78, 216, 0.2); background: linear-gradient(90deg, rgba(147, 197, 253, 1), rgba(191, 219, 254, 0.99) 24%, rgba(219, 234, 254, 0.99) 46%, rgba(239, 246, 255, 1) 70%), #fff; @@ -528,9 +556,10 @@ .app-chat-panel__conversation-main { display: flex; - flex: 1; + flex: 1 1 0%; flex-direction: column; - width: 100%; + width: auto; + max-width: 100%; min-width: 0; min-height: 0; position: relative; @@ -793,7 +822,7 @@ .app-chat-panel__title-group { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; min-width: 0; max-width: 100%; @@ -1206,6 +1235,14 @@ width: 100%; } +.app-chat-message-stack--artifact-only { + gap: 0; +} + +.app-chat-message-stack--artifact-only .app-chat-message-stack__previews { + gap: 10px; +} + .app-chat-message--codex { --app-chat-message-fade-end: rgba(248, 251, 255, 0.96); margin-left: 8px; @@ -1325,11 +1362,37 @@ max-width: 100%; font-size: 12px; line-height: 1.45; - white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } +.app-chat-message__block { + white-space: pre-wrap; +} + +.app-chat-message__block + .app-chat-message__block { + margin-top: 4px; +} + +.app-chat-message__block--spacer { + min-height: calc(1.45em * 0.7); +} + +.app-chat-message__block--image { + white-space: normal; +} + +.app-chat-message__inline-image { + display: block; + width: min(100%, 560px); + margin-top: 2px; +} + +.app-chat-message__body a { + text-decoration: underline; + text-underline-offset: 2px; +} + .app-chat-message__body--collapsed { position: relative; max-height: calc(1.45em * 6); @@ -1370,7 +1433,7 @@ gap: 8px; width: 100%; max-width: none; - padding: 8px 10px 10px; + padding: 8px 0 10px; border: 1px solid rgba(148, 163, 184, 0.22); background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); @@ -1388,13 +1451,18 @@ .app-chat-message-stack--codex .app-chat-preview-card, .app-chat-message-stack--system .app-chat-preview-card { - margin-left: 8px; - margin-right: 24px; + margin-left: 0; + margin-right: 0; } .app-chat-message-stack--user .app-chat-preview-card { - margin-left: 24px; - margin-right: 8px; + margin-left: 0; + margin-right: 0; +} + +.app-chat-message-stack--artifact-only .app-chat-preview-card { + margin-left: 0; + margin-right: 0; } .app-chat-preview-card__header { @@ -1405,6 +1473,13 @@ padding: 8px 10px; } +.app-chat-preview-card__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + .app-chat-preview-card--collapsed .app-chat-preview-card__header { border: 1px solid rgba(148, 163, 184, 0.22); background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); @@ -1429,6 +1504,8 @@ margin-top: 1px; color: #475569; background: rgba(226, 232, 240, 0.9); + border-radius: 999px; + font-size: 12px; } .app-chat-preview-card__titles { @@ -1440,12 +1517,15 @@ overflow: hidden; } +.app-chat-preview-card__label, +.app-chat-preview-card__kind, .app-chat-preview-card__label.ant-typography, .app-chat-preview-card__kind.ant-typography { margin: 0; max-width: 100%; } +.app-chat-preview-card__label, .app-chat-preview-card__label.ant-typography { font-size: 12px; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; @@ -1455,10 +1535,12 @@ text-overflow: ellipsis; } +.app-chat-preview-card__kind, .app-chat-preview-card__kind.ant-typography { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; + color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1471,6 +1553,13 @@ flex: 0 0 auto; } +.app-chat-preview-card__action.ant-btn { + height: 22px; + min-width: 22px; + padding: 0; + color: #475569; +} + @media (max-width: 1180px) { .app-chat-panel { height: 100%; @@ -1496,6 +1585,7 @@ } .app-chat-panel__conversation-shell, + .app-chat-panel__stack--chat, .app-chat-panel__conversation-main, .app-chat-panel__conversation-view, .app-chat-panel__conversation-view-inner, @@ -1527,9 +1617,15 @@ .app-chat-message-stack--codex .app-chat-preview-card, .app-chat-message-stack--system .app-chat-preview-card, .app-chat-message-stack--user .app-chat-preview-card { - margin-left: 4px; - margin-right: 4px; - max-width: calc(100% - 8px); + margin-left: 0; + margin-right: 0; + max-width: 100%; + } + + .app-chat-message-stack--artifact-only .app-chat-preview-card { + margin-left: 0; + margin-right: 0; + max-width: 100%; } .app-chat-panel__composer-queue { @@ -1537,6 +1633,21 @@ } } +@media (min-width: 1181px) and (max-width: 1366px) { + .app-chat-panel__conversation-list { + flex: 0 0 clamp(208px, 19vw, 240px); + width: clamp(208px, 19vw, 240px); + min-width: clamp(208px, 19vw, 240px); + max-width: clamp(208px, 19vw, 240px); + } + + .app-chat-panel__conversation-main, + .app-chat-panel__conversation-view, + .app-chat-panel__conversation-view-inner { + width: auto; + } +} + .app-chat-preview-card__body { display: flex; min-height: 0; @@ -1545,6 +1656,84 @@ width: 100%; } +.app-chat-preview-card--fullscreen { + position: fixed; + inset: 0; + z-index: 1400; + width: 100vw; + min-width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + margin: 0 !important; + gap: 0; + padding: 0; + border: 0; + border-radius: 0; + background: #f8fafc; + box-shadow: 0 18px 48px rgba(15, 23, 42, 0.26); +} + +.app-chat-preview-card--fullscreen .app-chat-preview-card__header { + position: sticky; + top: 0; + z-index: 1; + padding: 12px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96)); +} + +.app-chat-preview-card--fullscreen .app-chat-preview-card__body { + flex: 1 1 auto; + min-height: 0; + width: 100vw; + max-width: 100vw; + padding-top: 0; + border-top: 0; + overflow: hidden; +} + +.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich { + width: 100vw; + max-width: 100vw; +} + +.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich, +.app-chat-preview-card--fullscreen .codex-diff-previewer, +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list, +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section, +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body, +.app-chat-preview-card--fullscreen .previewer-ui, +.app-chat-preview-card--fullscreen .previewer-ui__editor, +.app-chat-preview-card--fullscreen .previewer-ui__editor-body { + height: 100%; + width: 100%; + max-width: none; +} + +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list { + gap: 0; +} + +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section { + border-width: 0 0 1px; + border-radius: 0; +} + +.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toggle { + padding-inline: 16px 88px; +} + +.app-chat-preview-card--fullscreen .previewer-ui__editor { + border-width: 0; + border-radius: 0; +} + +.app-chat-preview-card--fullscreen .previewer-ui__editor-body { + max-height: none; + padding-inline: 0; +} + .app-chat-panel__preview-rich { width: 100%; min-width: 0; @@ -1556,6 +1745,13 @@ width: 100%; } +.app-chat-panel__preview-rich .codex-diff-previewer, +.app-chat-panel__preview-rich .codex-diff-previewer__diff-body, +.app-chat-panel__preview-rich .previewer-ui, +.app-chat-panel__preview-rich .previewer-ui__body { + width: 100%; +} + .app-chat-panel__preview-rich .previewer-ui__editor { border-color: rgba(15, 23, 42, 0.58); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); @@ -1583,6 +1779,10 @@ color: #e2e8f0; } +.app-chat-panel__preview-rich .codex-diff-previewer__diff-list--expand-all .codex-diff-previewer__diff-section { + border-radius: 0; +} + .app-chat-panel__preview-rich--markdown { padding: 4px 2px 0; } @@ -1762,7 +1962,7 @@ font-size: 13px; line-height: 1.4; min-height: 88px; - padding: 8px 14px 12px; + padding: 8px 76px 16px 14px; } .app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea { @@ -1806,6 +2006,37 @@ display: none; } +.app-chat-panel__composer-clear.ant-btn { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 2; + height: 28px; + padding: 0 10px; + border-radius: 999px; + color: rgba(71, 85, 105, 0.88); + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(148, 163, 184, 0.24); + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); + opacity: 0; + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.16s ease; + transform: translateY(4px); +} + +.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.app-chat-panel__composer-clear.ant-btn:disabled { + opacity: 0; + pointer-events: none; +} + .app-chat-panel__composer-attachment-strip { display: flex; flex-wrap: wrap; @@ -1943,6 +2174,7 @@ .app-chat-panel__preview-stage > * { width: 100%; + height: 100%; } .app-chat-panel__preview-loading { @@ -1973,6 +2205,10 @@ pointer-events: none; } +.app-chat-panel__conversation-view-inner.is-busy { + user-select: auto; +} + .app-chat-panel__conversation-loading { position: absolute; inset: 0; @@ -1994,6 +2230,34 @@ text-align: center; } +.app-chat-panel__busy-overlay { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 28px 24px; + border-radius: 24px; + background: + radial-gradient(circle at top, rgba(147, 197, 253, 0.26), transparent 48%), + linear-gradient(180deg, rgba(248, 250, 252, 0.64), rgba(241, 245, 249, 0.74)); + backdrop-filter: blur(4px); + text-align: center; +} + +.app-chat-panel__busy-overlay strong { + color: #0f172a; +} + +.app-chat-panel__busy-overlay span { + color: #475569; + font-size: 12px; +} + .app-chat-panel__preview-image, .app-chat-panel__preview-video, .app-chat-panel__preview-frame { @@ -2034,13 +2298,50 @@ } .app-chat-panel__preview-modal .ant-modal-body { - padding-top: 12px; + padding: 12px 0 0; + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; } .app-chat-panel__preview-modal { z-index: 1600; } +.app-chat-panel__preview-modal .ant-modal-content { + display: flex; + flex-direction: column; + min-height: 0; + height: 100dvh; + max-height: 100dvh; + padding: 0; + border-radius: 0; +} + +.app-chat-panel__preview-modal .ant-modal-header { + margin-bottom: 0; + padding: 16px 20px 12px; + border-radius: 0; +} + +.app-chat-panel__preview-modal .ant-modal-title { + padding-right: 40px; +} + +.app-chat-panel__preview-modal .ant-modal-footer { + margin-top: 0; + padding: 0 20px 16px; + border-top: 0; +} + +.app-chat-panel__preview-stage--modal { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + .app-chat-panel__delete-confirm-modal { z-index: 1700 !important; } @@ -2052,13 +2353,59 @@ .app-chat-panel__preview-modal-body { display: flex; + flex: 1 1 auto; flex-direction: column; - gap: 12px; + gap: 0; + min-height: 0; + overflow: hidden; } .app-chat-panel__preview-modal-meta { display: flex; justify-content: flex-start; + padding: 0 20px 12px; +} + +.app-chat-panel__preview-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + width: 100%; +} + +.app-chat-panel__preview-modal .app-chat-panel__preview-rich, +.app-chat-panel__preview-modal .previewer-ui, +.app-chat-panel__preview-modal .previewer-ui__editor, +.app-chat-panel__preview-modal .previewer-ui__editor-body, +.app-chat-panel__preview-modal .codex-diff-previewer, +.app-chat-panel__preview-modal .codex-diff-previewer__diff-list, +.app-chat-panel__preview-modal .codex-diff-previewer__diff-section, +.app-chat-panel__preview-modal .codex-diff-previewer__diff-body { + height: 100%; + width: 100%; + max-width: 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, +.app-chat-panel__preview-modal .app-chat-panel__preview-video, +.app-chat-panel__preview-modal .app-chat-panel__preview-frame { + border-left-width: 0; + border-right-width: 0; + border-radius: 0; +} + +.app-chat-panel__preview-modal .previewer-ui__editor-body { + max-height: none; + padding-inline: 0; +} + +@media (max-width: 720px) { + .app-chat-panel__preview-modal-footer { + justify-content: flex-end; + } } .app-chat-panel__connection-dot--connecting { @@ -2429,6 +2776,14 @@ } @media (max-width: 768px) { + .app-chat-panel__conversation-list { + flex: 1 1 100%; + width: 100%; + min-width: 100%; + max-width: 100%; + border-right: 0; + } + .app-chat-runtime { overflow: hidden; } @@ -2560,6 +2915,63 @@ height: 100%; } +.chat-v2__pane { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + overflow: hidden; +} + +.chat-v2__pane--list { + flex: 0 0 320px; + max-width: 320px; +} + +.chat-v2__pane--room, +.chat-v2__pane--runtime, +.chat-v2__pane--errors { + flex: 1 1 auto; +} + +.chat-v2__pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid rgba(226, 232, 240, 0.9); + background: rgba(248, 250, 252, 0.96); +} + +.chat-v2__pane > .ant-input-search, +.chat-v2__pane > .ant-input-affix-wrapper, +.chat-v2__pane > .ant-input-group-wrapper { + margin: 12px 16px 0; +} + +.chat-v2__state { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + text-align: center; +} + +.chat-v2__conversation-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 12px 10px 10px; +} + .chat-v2__conversation-list .ant-list-item { padding: 0; border-block-end: 0; @@ -2604,6 +3016,13 @@ font-size: 13px; } +@media (max-width: 1180px) { + .chat-v2__pane--list { + flex-basis: auto; + max-width: none; + } +} + @media (min-width: 1181px) { .app-chat-panel { background: @@ -2640,18 +3059,19 @@ } .app-chat-panel__conversation-item { - border-color: rgba(148, 163, 184, 0.14); + border-color: transparent; background: rgba(255, 255, 255, 0.92); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04); } .app-chat-panel__conversation-item--active { - border-color: rgba(100, 116, 139, 0.26); + border-color: transparent; background: rgba(248, 250, 252, 0.98); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); } .app-chat-panel__conversation-item--unread { - border-color: rgba(148, 163, 184, 0.28); + border-color: transparent; background: linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%), #fff; @@ -2661,7 +3081,7 @@ } .app-chat-panel__conversation-item--unread-section { - border-color: rgba(148, 163, 184, 0.32); + border-color: transparent; background: linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%), #fff; @@ -2679,7 +3099,7 @@ } .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { - border-color: rgba(100, 116, 139, 0.34); + border-color: transparent; background: linear-gradient(90deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 0.99) 34%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), #fff; @@ -2689,7 +3109,7 @@ } .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section { - border-color: rgba(100, 116, 139, 0.38); + border-color: transparent; background: linear-gradient(135deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 1) 36%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), #fff; diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 3797383..1a6b4bb 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -3,12 +3,12 @@ import { BellFilled, BellOutlined, CloseOutlined, + CopyOutlined, DownloadOutlined, EditOutlined, ExclamationCircleOutlined, FullscreenExitOutlined, FullscreenOutlined, - LinkOutlined, MessageOutlined, PaperClipOutlined, PlusOutlined, @@ -23,7 +23,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useAppStore } from '../../store'; import { useAppConfig } from './appConfig'; import { chatConnectionGateway, chatGateway } from './chatV2'; -import { emitChatConversationsUpdated } from './chatV2/data/chatClientEvents'; import { useConversationComposerController } from './chatV2/hooks/useConversationComposerController'; import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController'; import { useConversationListController } from './chatV2/hooks/useConversationListController'; @@ -33,12 +32,15 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController'; import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl'; +import { triggerResourceDownload } from './mainChatPanel/downloadUtils'; +import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; import { useTokenAccess } from './tokenAccess'; import { ChatConversationView, ChatRuntimeDashboard, + copyPreviewContent, copyText, createActivityLogPlaceholder, createChatMessage, @@ -47,6 +49,7 @@ import { getStoredChatSessionLastTypeId, isPreparingChatReplyText, setStoredChatSessionLastTypeId, + sortChatConversationSummaries, upsertChatMessage, useErrorLogs, } from './mainChatPanel'; @@ -62,6 +65,7 @@ import type { ChatViewContext, MainChatPanelProps, } from './mainChatPanel/types'; +import { buildChatPath } from './routes'; import './MainChatPanel.css'; import './MainChatPanel.hotfix.css'; @@ -209,7 +213,7 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig } function getConversationLatestActivityTime(item: ChatConversationSummary) { - const latestTimestamp = item.lastMessageAt || item.updatedAt || item.createdAt; + const latestTimestamp = item.lastMessageAt || item.createdAt; const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0; return Number.isFinite(parsedTime) ? parsedTime : 0; @@ -446,7 +450,7 @@ function extractPreviewItems(messages: ChatMessage[]) { const orderedMessages = [...messages].reverse(); orderedMessages.forEach((message) => { - const matches = message.text.match(urlPattern) ?? []; + const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)]; matches.forEach((matchedUrl) => { const normalizedUrl = normalizePreviewUrl(matchedUrl); @@ -613,6 +617,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null; const requestedSessionId = getSessionIdFromSearch(location.search); const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search); + const requestedChatView = getRequestedChatViewFromSearch(location.search); const [activeSessionId, setActiveSessionId] = useState(''); const sessionMessageCacheRef = useRef>(new Map()); const [isMobileConversationView, setIsMobileConversationView] = useState(false); @@ -621,15 +626,22 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false); const [messages, setMessages] = useState([]); - const [activeView, setActiveView] = useState(initialView === 'errors' ? 'errors' : 'chat'); + const [activeView, setActiveView] = useState(() => { + if (requestedChatView) { + return requestedChatView; + } + + return initialView === 'errors' ? 'errors' : 'chat'; + }); const [copiedMessageId, setCopiedMessageId] = useState(null); const [requestItemsState, setRequestItemsState] = useState([]); const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null); const [isConversationContentLoading, setIsConversationContentLoading] = useState(true); const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.'); + const [conversationRoomReloadKey, setConversationRoomReloadKey] = useState(0); const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false); const [hasOlderMessages, setHasOlderMessages] = useState(false); - const [, setOldestLoadedMessageId] = useState(null); + const [oldestLoadedMessageId, setOldestLoadedMessageId] = useState(null); const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false); const [isMaximized, setIsMaximized] = useState(false); const [isResourceStripOpen, setIsResourceStripOpen] = useState(false); @@ -728,19 +740,62 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; const handleCreateConversation = async () => { const sessionId = createConversationSessionId(); + const now = new Date().toISOString(); + const optimisticItem: ChatConversationSummary = { + sessionId, + clientId: null, + title: '새 대화', + chatTypeId: selectedChatType?.id ?? null, + contextLabel: selectedChatType?.name ?? null, + contextDescription: selectedChatType?.description ?? null, + notifyOffline: true, + hasUnreadResponse: false, + currentRequestId: null, + currentJobStatus: null, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: null, + lastMessagePreview: '', + createdAt: now, + updatedAt: now, + lastMessageAt: null, + }; + + setConversationItems((previous) => + sortChatConversationSummaries([optimisticItem, ...previous.filter((entry) => entry.sessionId !== sessionId)]), + ); + openConversationSession(sessionId); try { const item = await chatGateway.createConversation({ sessionId, title: '새 대화', + chatTypeId: selectedChatType?.id ?? null, contextLabel: selectedChatType?.name, contextDescription: selectedChatType?.description, notifyOffline: true, }); - setConversationItems((previous) => [item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]); - openConversationSession(item.sessionId); + setConversationItems((previous) => + sortChatConversationSummaries([item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]), + ); } catch (error) { + const currentUrlSessionId = + typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('sessionId')?.trim() ?? '' : ''; + const shouldResetFailedSession = currentUrlSessionId === sessionId; + + sessionMessageCacheRef.current.delete(sessionId); + setConversationItems((previous) => previous.filter((entry) => entry.sessionId !== sessionId)); + + if (shouldResetFailedSession) { + replaceChatSessionInUrl(''); + setActiveSessionId((current) => (current === sessionId ? '' : current)); + setMessages([]); + setRequestItems([]); + setIsConversationPaneClosed(false); + setIsMobileConversationView(!isMobileViewport); + } + messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.'); } }; @@ -770,16 +825,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } setConversationItems((previous) => - previous.map((item) => - item.sessionId === sessionId - ? { - ...item, - title: buildConversationTitleFromRequestText(text, item.title), - lastMessagePreview: nextPreview, - lastMessageAt: requestedAt, - updatedAt: requestedAt, - } - : item, + sortChatConversationSummaries( + previous.map((item) => + item.sessionId === sessionId + ? { + ...item, + title: buildConversationTitleFromRequestText(text, item.title), + lastMessagePreview: nextPreview, + lastMessageAt: requestedAt, + updatedAt: requestedAt, + } + : item, + ), ), ); }; @@ -789,10 +846,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const exists = previous.some((item) => item.sessionId === sessionId); if (!exists) { - return [detail.item, ...previous]; + return sortChatConversationSummaries([detail.item, ...previous]); } - return previous.map((item) => (item.sessionId === sessionId ? detail.item : item)); + return sortChatConversationSummaries( + previous.map((item) => (item.sessionId === sessionId ? detail.item : item)), + ); }); sessionMessageCacheRef.current.set(sessionId, detail.messages); @@ -980,37 +1039,39 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const responseTimestamp = new Date().toISOString(); setConversationItems((previous) => - previous.map((item) => - item.sessionId === sessionId - ? { - ...item, - title: - incomingMessage.author === 'user' - ? buildConversationTitleFromRequestText(incomingMessage.text, item.title) - : item.title, - lastMessagePreview: createConversationPreviewText(incomingMessage.text), - lastMessageAt: responseTimestamp, - updatedAt: responseTimestamp, - hasUnreadResponse: - hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse, - currentRequestId: - incomingMessage.author === 'codex' && incomingMessage.clientRequestId - ? null - : item.currentRequestId, - currentJobStatus: - incomingMessage.author === 'codex' && incomingMessage.clientRequestId - ? null - : item.currentJobStatus, - currentJobMessage: - incomingMessage.author === 'codex' && incomingMessage.clientRequestId - ? null - : item.currentJobMessage, - currentQueueSize: - incomingMessage.author === 'codex' && incomingMessage.clientRequestId - ? 0 - : item.currentQueueSize, - } - : item, + sortChatConversationSummaries( + previous.map((item) => + item.sessionId === sessionId + ? { + ...item, + title: + incomingMessage.author === 'user' + ? buildConversationTitleFromRequestText(incomingMessage.text, item.title) + : item.title, + lastMessagePreview: createConversationPreviewText(incomingMessage.text), + lastMessageAt: responseTimestamp, + updatedAt: responseTimestamp, + hasUnreadResponse: + hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse, + currentRequestId: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentRequestId, + currentJobStatus: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentJobStatus, + currentJobMessage: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentJobMessage, + currentQueueSize: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? 0 + : item.currentQueueSize, + } + : item, + ), ), ); @@ -1033,25 +1094,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } }; - const shouldBlockConversationWhileLoading = useCallback( - (sessionId: string) => { - const normalizedSessionId = sessionId.trim(); - - if (!normalizedSessionId) { - return false; - } - - const cachedMessages = getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId); - - if (cachedMessages.length > 0) { - return false; - } - - const conversation = conversationItemsRef.current.find((item) => item.sessionId === normalizedSessionId) ?? null; - return !(conversation?.currentJobStatus === 'queued' || conversation?.currentJobStatus === 'started'); - }, - [], - ); const { socketRef, connectionState } = chatConnectionGateway.useConnection({ sessionId: activeSessionId, currentContext, @@ -1167,8 +1209,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }); const { loadOlderMessages } = useConversationRoomController({ activeSessionId, + oldestLoadedMessageId, + reloadKey: conversationRoomReloadKey, connectionState, - shouldBlockConversationWhileLoading, captureViewportRestoreSnapshot, sessionMessageCacheRef, messagesRef, @@ -1245,7 +1288,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = previewItems, selectedChatTypeId: selectedChatType?.id ?? null, composerRef, - sessionMessageCacheRef, setActiveSystemStatus, setComposerAttachments, setCopiedMessageId, @@ -1254,6 +1296,53 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsSystemStatusPending, setMessages, }); + const openPreviewModal = useCallback( + (previewId: string) => { + setActivePreviewId(previewId); + setIsPreviewModalOpen(true); + }, + [setActivePreviewId, setIsPreviewModalOpen], + ); + const handleCopyActivePreview = useCallback(async () => { + if (!activePreview) { + return; + } + + try { + const result = await copyPreviewContent({ + kind: activePreview.kind, + url: activePreview.url, + fallbackText: previewText, + }); + + if (result === 'image') { + message.success('이미지를 복사했습니다.'); + return; + } + + if (result === 'url') { + message.success('이미지 URL을 복사했습니다.'); + return; + } + + message.success('복사했습니다.'); + } catch (error) { + message.error(error instanceof Error ? error.message : '복사에 실패했습니다.'); + } + }, [activePreview, previewText]); + + const handleDownloadActivePreview = useCallback(() => { + if (!activePreview) { + return; + } + + try { + const fileName = activePreview.url.split('/').pop()?.trim() || activePreview.label; + triggerResourceDownload(activePreview.url, fileName); + } catch { + void messageApi.error('다운로드에 실패했습니다.'); + } + }, [activePreview, messageApi]); const markConversationReadLocally = (sessionId: string) => { const normalizedSessionId = sessionId.trim(); @@ -1321,7 +1410,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = {item.title || '새 대화'} - {formatConversationListTimestamp(item.lastMessageAt || item.updatedAt)} + {formatConversationListTimestamp(item.lastMessageAt || item.createdAt)} {item.sessionId} @@ -1360,6 +1449,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; const replaceChatSessionInUrl = (sessionId: string) => { + if (location.pathname !== buildChatPath('live')) { + return; + } + const nextSessionId = sessionId.trim(); const searchParams = new URLSearchParams(location.search); const currentSessionId = searchParams.get('sessionId')?.trim() ?? ''; @@ -1388,7 +1481,37 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ); }; + const replaceChatViewInUrl = useCallback( + (view: ChatPanelView) => { + if (location.pathname !== buildChatPath('live')) { + return; + } + + const searchParams = new URLSearchParams(location.search); + + if (view === 'chat') { + searchParams.delete('chatView'); + } else { + searchParams.set('chatView', view); + } + + const nextSearch = searchParams.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : '', + }, + { replace: true }, + ); + }, + [location.pathname, location.search, navigate], + ); + const clearRequestedRuntimeLogInUrl = useCallback(() => { + if (location.pathname !== buildChatPath('live')) { + return; + } + const searchParams = new URLSearchParams(location.search); if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) { @@ -1410,6 +1533,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const openConversationSession = (sessionId: string) => { replaceChatSessionInUrl(sessionId); + const now = new Date().toISOString(); + const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? []; + const hasCachedMessages = cachedMessages.length > 0; if (sessionId === activeSessionId && !isConversationPaneClosed) { if (isMobileViewport) { @@ -1419,24 +1545,56 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } - chatConnectionGateway.resetLastReceivedEventId(sessionId); setConversationLoadingLabel('대화 내용을 불러오는 중입니다.'); - setIsConversationContentLoading(shouldBlockConversationWhileLoading(sessionId)); + setIsConversationContentLoading(true); setIsDeferringAuxiliaryChatRequests(true); setHasOlderMessages(false); setOldestLoadedMessageId(null); setIsLoadingOlderMessages(false); shouldStickToBottomRef.current = true; setShowScrollToBottom(false); + setConversationItems((previous) => { + const exists = previous.some((item) => item.sessionId === sessionId); + + if (exists) { + return previous; + } + + return [ + { + sessionId, + clientId: null, + title: '대화 내용을 불러오는 중입니다.', + chatTypeId: null, + contextLabel: null, + contextDescription: null, + notifyOffline: true, + hasUnreadResponse: false, + currentRequestId: null, + currentJobStatus: null, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: null, + lastMessagePreview: '', + createdAt: now, + updatedAt: now, + lastMessageAt: null, + }, + ...previous, + ]; + }); setActiveSessionId(sessionId); + if (sessionId === activeSessionId) { + setConversationRoomReloadKey((previous) => previous + 1); + } setIsConversationPaneClosed(false); - setActiveView('chat'); if (isMobileViewport) { setIsMobileConversationView(true); } - setMessages(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId)); + setMessages(hasCachedMessages ? cachedMessages : []); + setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId)); setActivePreviewId(null); setIsPreviewModalOpen(false); setActiveSystemStatus(null); @@ -1486,10 +1644,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = conversationItemsRef.current = conversationItems; }, [conversationItems]); - useEffect(() => { - emitChatConversationsUpdated(conversationItems); - }, [conversationItems]); - useEffect(() => { messagesRef.current = messages; }, [messages]); @@ -1507,6 +1661,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [messages]); useEffect(() => { + if (activeView !== 'chat') { + return; + } + if ( !isActiveChatSessionInForeground({ sessionId: activeSessionId, @@ -1718,7 +1876,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [activeSessionId, selectedChatTypeId]); useEffect(() => { - const nextView = initialView === 'errors' ? 'errors' : 'chat'; + const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat'); if (nextView === 'errors' && !hasAccess) { setActiveView('chat'); @@ -1726,7 +1884,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } setActiveView(nextView); - }, [hasAccess, initialView]); + }, [hasAccess, initialView, requestedChatView]); useEffect(() => { return () => { @@ -1762,6 +1920,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [activeConversation?.sessionId, activeConversation?.title]); useEffect(() => { + if (location.pathname !== buildChatPath('live')) { + handledRequestedSessionIdRef.current = ''; + return; + } + if (!requestedSessionId) { handledRequestedSessionIdRef.current = ''; return; @@ -1771,12 +1934,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } - const requestedConversationExists = conversationItems.some((item) => item.sessionId === requestedSessionId); - - if (!requestedConversationExists) { - return; - } - if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) { return; } @@ -1787,14 +1944,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (isMobileViewport && !isConversationPaneClosed) { setIsMobileConversationView(true); } - if (!isConversationPaneClosed) { - setActiveView('chat'); - } return; } openConversationSession(requestedSessionId); - }, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, requestedSessionId]); + }, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]); useEffect(() => { if (requestedSessionId) { @@ -1839,7 +1993,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (!activeSessionId.trim()) { void chatGateway.listConversations().then((items) => { if (!isCancelled) { - setConversationItems(items); + setConversationItems(sortChatConversationSummaries(items)); } }).catch(() => { // 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다. @@ -1879,13 +2033,23 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } const latestMessage = getLatestConversationPreviewMessage(messages); + const nextTitle = buildConversationTitleFromMessages(messages, item.title); const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview; + const nextLastMessageAt = latestMessage?.timestamp?.trim() || item.lastMessageAt; + + if ( + item.title === nextTitle && + item.lastMessagePreview === nextPreview && + item.lastMessageAt === nextLastMessageAt + ) { + return item; + } return { ...item, - title: buildConversationTitleFromMessages(messages, item.title), + title: nextTitle, lastMessagePreview: nextPreview, - lastMessageAt: latestMessage ? new Date().toISOString() : item.lastMessageAt, + lastMessageAt: nextLastMessageAt, }; }), ); @@ -1896,6 +2060,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } + if (activeView !== 'chat') { + return; + } + if ( !isActiveChatSessionInForeground({ sessionId: activeConversation.sessionId, @@ -2057,6 +2225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }`} aria-label="대화 보기" onClick={() => { + replaceChatViewInUrl('chat'); setActiveView('chat'); setIsTitleClusterOpen(false); }} @@ -2070,6 +2239,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }`} aria-label="런타임 보기" onClick={() => { + replaceChatViewInUrl('runtime'); setActiveView('runtime'); setIsTitleClusterOpen(false); }} @@ -2084,6 +2254,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }`} aria-label="에러 로그 보기" onClick={() => { + replaceChatViewInUrl('errors'); setActiveView('errors'); setIsTitleClusterOpen(false); }} @@ -2183,7 +2354,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }`} styles={{ body: { height: '100%' } }} > -
+
{activeView === 'chat' ? ( <> {!isMobileViewport || !isMobileConversationView || isConversationPaneClosed ? ( @@ -2298,7 +2469,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = text: item.userText, }))} chatTypeOptions={chatTypeOptions} - previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, kind: item.kind }))} + previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))} isResourceStripOpen={isResourceStripOpen} isComposerDisabled={!selectedChatType} isComposerAttachmentUploading={isComposerAttachmentUploading} @@ -2314,6 +2485,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = onSelectChatType={setSelectedChatTypeId} onSend={handleSend} onSendImmediate={handleSendImmediate} + onClearDraft={() => { + setDraft(''); + }} onToggleResourceStrip={() => { setIsResourceStripOpen((current) => !current); }} @@ -2322,10 +2496,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setShowScrollToBottom(false); scrollViewportToBottom(); }} - onOpenPreview={(previewId) => { - setActivePreviewId(previewId); - setIsPreviewModalOpen(true); - }} + onOpenPreview={openPreviewModal} onCopyMessage={(message) => { void handleCopyMessage(message); }} @@ -2411,8 +2582,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = > - 최근 대화가 길어서 이번 요청에는 최근 {pendingContextConfirm?.includedContextCount ?? 0}개만 문맥으로 넣고, - 이전 {pendingContextConfirm?.omittedContextCount ?? 0}개는 제외합니다. + 최근 대화가 길어서 이번 요청은 저장된 문맥 제한인 최근 {appConfig.chat.maxContextMessages}개 메시지, + 최대 {appConfig.chat.maxContextChars}자 안에서만 참조됩니다. 이번 전송에는 최근{' '} + {pendingContextConfirm?.includedContextCount ?? 0}개 메시지만 문맥에 포함되고, 이전{' '} + {pendingContextConfirm?.omittedContextCount ?? 0}개 메시지는 제외됩니다. 전체 맥락이 꼭 필요하면 요청 내용을 더 구체화하거나 새로 정리한 뒤 보내는 편이 안전합니다. @@ -2424,25 +2597,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = title={activePreview ? `${activePreview.label} preview` : 'preview'} footer={ activePreview ? ( - - - - +
+ +
) : null } onCancel={() => { setIsPreviewModalOpen(false); }} - width={960} + width="100vw" zIndex={1600} className="app-chat-panel__preview-modal" > diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index c317b83..c1ee352 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -3,7 +3,6 @@ import { BellOutlined, ClockCircleOutlined, CopyOutlined, - DownloadOutlined, FileMarkdownOutlined, LoadingOutlined, MenuFoldOutlined, @@ -23,13 +22,12 @@ import { InputNumber, Layout, Modal, - Progress, Select, Segmented, Space, Typography, } from 'antd'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { fetchPlanItems } from '../../features/planBoard/api'; import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters'; @@ -45,7 +43,6 @@ import { type AppConfig, type PlanCostTimeUnit, } from './appConfig'; -import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate'; import { fetchWebPushConfig, registerPwaNotificationToken, @@ -60,6 +57,7 @@ import { getSavedPwaNotificationToken, setSavedPwaNotificationToken, } from './notificationIdentity'; +import { resetNonAuthClientState } from './appMaintenance'; import { ALLOWED_REGISTRATION_TOKEN, setRegisteredAccessToken, @@ -465,19 +463,6 @@ function getSettingsModalTitle(modal: SettingsModalKey) { } } -function getAppUpdateStatusLabel(status: AppUpdateStatus) { - switch (status) { - case 'available': - return '업데이트 가능'; - case 'updating': - return '업데이트 중'; - case 'ready': - return '최신 상태'; - default: - return '확인 중'; - } -} - function formatDateTimeLabel(value: string | null) { if (!value) { return '-'; @@ -490,29 +475,36 @@ function formatDateTimeLabel(value: string | null) { } return new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', dateStyle: 'medium', timeStyle: 'short', }).format(parsed); } -function getWorkServerUpdateStatusLabel(item: ServerCommandItem | null) { +function getServerVersionStatusClassName(item: ServerCommandItem | null) { if (!item) { - return '확인 전'; + return 'app-header__server-version-indicator--stale'; } - if (item.buildRequired) { - return '소스 변경 감지됨'; + return item.buildRequired || item.updateAvailable + ? 'app-header__server-version-indicator--stale' + : 'app-header__server-version-indicator--latest'; +} + +function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) { + return formatDateTimeLabel(item?.latestSourceChangeAt ?? null); +} + +function getServerVersionStatusTitle(item: ServerCommandItem | null, label: string) { + if (!item) { + return `${label} 최신 버전 확인 전`; } - if (item.updateAvailable) { - return '새 빌드 대기 중'; + if (item.buildRequired || item.updateAvailable) { + return `${label} 최신 버전 아님`; } - if (item.availability === 'online') { - return '최신 빌드 실행 중'; - } - - return '상태 확인 필요'; + return `${label} 최신 버전`; } function getClientNotificationPermission(): ClientNotificationPermissionState { @@ -900,14 +892,6 @@ export function MainHeader({ ); const [webPushConfigured, setWebPushConfigured] = useState(false); const [isStandaloneMode, setIsStandaloneMode] = useState(false); - const [appUpdateStatus, setAppUpdateStatus] = useState(() => getAppUpdateSnapshot().status); - const [appUpdateSupported, setAppUpdateSupported] = useState(() => getAppUpdateSnapshot().supported); - const [appUpdateProgressPercent, setAppUpdateProgressPercent] = useState( - () => getAppUpdateSnapshot().progressPercent, - ); - const [appUpdateCurrentTaskLabel, setAppUpdateCurrentTaskLabel] = useState( - () => getAppUpdateSnapshot().currentTaskLabel, - ); const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot()); const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState(() => chatConnectionGateway.getSharedRuntimeSnapshot(), @@ -921,14 +905,17 @@ export function MainHeader({ const [runtimeLogLoading, setRuntimeLogLoading] = useState(false); const [runtimeLogError, setRuntimeLogError] = useState(''); const [runtimeLogDetail, setRuntimeLogDetail] = useState(null); - const [appUpdateFeedback, setAppUpdateFeedback] = useState(null); - const [appUpdateCopyFeedback, setAppUpdateCopyFeedback] = useState(null); - const previousAppUpdateStatusRef = useRef(getAppUpdateSnapshot().status); + const [updateCheckFeedback, setUpdateCheckFeedback] = useState(null); + const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState(null); + const [clientResetting, setClientResetting] = useState(false); + const [clientResetFeedback, setClientResetFeedback] = useState(null); + const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState(null); + const [testServerStatus, setTestServerStatus] = useState(null); const [workServerStatus, setWorkServerStatus] = useState(null); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); - const [workServerRestarting, setWorkServerRestarting] = useState(false); - const [workServerUpdateFeedback, setWorkServerUpdateFeedback] = useState(null); - const [workServerUpdateCopyFeedback, setWorkServerUpdateCopyFeedback] = useState(null); + const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null); + const [serverRestartFeedback, setServerRestartFeedback] = useState(null); + const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState(null); const { registeredToken, hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); const [appConfigDraft, setAppConfigDraft] = useState(appConfig); @@ -946,22 +933,17 @@ export function MainHeader({ const notificationStatusClassName = notificationEnabled ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'; - const appUpdateStatusClassName = - appUpdateStatus === 'available' - ? 'app-header__status-dot--warning' - : appUpdateStatus === 'updating' - ? 'app-header__status-dot--progress' - : 'app-header__status-dot--active'; const chatConnectionStatusClassName = chatConnection.connectionState === 'connected' ? 'app-header__status-dot--active' : chatConnection.connectionState === 'connecting' ? 'app-header__status-dot--progress' : 'app-header__status-dot--inactive'; - const appPendingUpdateCount = appUpdateStatus === 'available' ? 1 : 0; + const testServerPendingUpdateCount = + testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0; const workServerPendingUpdateCount = workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; - const totalPendingUpdateCount = appPendingUpdateCount + workServerPendingUpdateCount; + const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount; const settingsStatusClassName = totalPendingUpdateCount >= 2 ? 'app-header__status-dot--inactive' @@ -1029,12 +1011,12 @@ export function MainHeader({ setRuntimeLogLoading(false); } }; - const canApplyAppUpdate = appUpdateSupported && appUpdateStatus === 'available'; - const canRefreshWorkServerStatus = hasAccess && !workServerRestarting && !workServerStatusLoading; - const canApplyWorkServerUpdate = + const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey; + const canResetClientState = !clientResetting; + const canRestartServers = hasAccess && - !workServerRestarting && - !workServerStatusLoading; + !workServerStatusLoading && + !serverRestartingKey; const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat); const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual( appConfig.worklogAutomation, @@ -1194,32 +1176,6 @@ export function MainHeader({ }; }, [isRuntimeModalOpen]); - useEffect(() => { - return subscribeAppUpdate((nextSnapshot) => { - setAppUpdateSupported(nextSnapshot.supported); - setAppUpdateStatus(nextSnapshot.status); - setAppUpdateProgressPercent(nextSnapshot.progressPercent); - setAppUpdateCurrentTaskLabel(nextSnapshot.currentTaskLabel); - }); - }, []); - - useEffect(() => { - const previousStatus = previousAppUpdateStatusRef.current; - - if (appUpdateStatus === 'available' && previousStatus !== 'available') { - setAppUpdateFeedback({ - tone: 'info', - message: import.meta.env.DEV - ? '개발 서버 변경 사항이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르면 반영됩니다.' - : '새 앱 버전이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르세요.', - }); - } else if (appUpdateStatus !== 'available' && previousStatus === 'available') { - setAppUpdateFeedback(null); - } - - previousAppUpdateStatusRef.current = appUpdateStatus; - }, [appUpdateStatus]); - useEffect(() => { setTokenInput(registeredToken); }, [registeredToken]); @@ -1248,11 +1204,12 @@ export function MainHeader({ useEffect(() => { if (!hasAccess) { + setTestServerStatus(null); setWorkServerStatus(null); return; } - void refreshWorkServerStatus(true); + void refreshUpdateTargets(true); }, [hasAccess]); useEffect(() => { @@ -1260,7 +1217,7 @@ export function MainHeader({ return; } - void refreshWorkServerStatus(true); + void refreshUpdateTargets(true); }, [activeSettingsModal, hasAccess, settingsModalOpen]); const ensureClientNotificationPermission = async () => { @@ -1318,27 +1275,27 @@ export function MainHeader({ }; const syncNotificationEnabled = async (nextEnabled: boolean) => { + if (notificationLoading) { + return; + } + + const previousEnabled = notificationEnabled; setNotificationCopyFeedback(null); + setNotificationLoading(true); + setNotificationEnabled(nextEnabled); if (nextEnabled) { + setNotificationFeedback({ tone: 'info', message: '알림 권한과 Web Push 등록 상태를 확인하는 중입니다.' }); const permissionGranted = await ensureClientNotificationPermission(); if (!permissionGranted) { setNotificationEnabled(false); + setNotificationLoading(false); return; } } - setNotificationLoading(true); - try { - const config = await fetchWebPushConfig(); - setWebPushConfigured(Boolean(config.enabled && config.publicKey)); - - if (!config.enabled || !config.publicKey) { - throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.'); - } - let registration = await getPushServiceWorkerRegistration(); if (!registration) { @@ -1407,6 +1364,13 @@ export function MainHeader({ } if (nextEnabled) { + const config = await fetchWebPushConfig(); + setWebPushConfigured(Boolean(config.enabled && config.publicKey)); + + if (!config.enabled || !config.publicKey) { + throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.'); + } + let subscription = await registration.pushManager.getSubscription(); const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); @@ -1442,7 +1406,7 @@ export function MainHeader({ setNotificationEnabled(false); setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' }); } catch (error) { - setNotificationEnabled(!nextEnabled); + setNotificationEnabled(previousEnabled); setNotificationFeedback({ tone: 'error', message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.', @@ -1515,40 +1479,24 @@ export function MainHeader({ } }; - const handleApplyAppUpdate = async () => { - setAppUpdateCopyFeedback(null); - - if (!appUpdateSupported) { - setAppUpdateFeedback({ tone: 'warning', message: '현재 환경에서는 앱 업데이트를 지원하지 않습니다.' }); - return; - } - - if (appUpdateStatus === 'updating') { - return; - } - - try { - const applied = await applyAppUpdate(); - - if (!applied) { - setAppUpdateFeedback({ tone: 'info', message: '현재 적용할 앱 업데이트가 없습니다.' }); - return; - } - - setAppUpdateFeedback({ tone: 'success', message: '앱 업데이트를 적용합니다.' }); - } catch (error) { - setAppUpdateFeedback({ - tone: 'error', - message: error instanceof Error ? error.message : '앱 업데이트 적용에 실패했습니다.', - }); - } + const refreshServerStatuses = async () => { + const items = await fetchServerCommands(); + const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null; + const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null; + setTestServerStatus(nextTestServerStatus); + setWorkServerStatus(nextWorkServerStatus); + return { + test: nextTestServerStatus, + 'work-server': nextWorkServerStatus, + } satisfies Record<'test' | 'work-server', ServerCommandItem | null>; }; - const refreshWorkServerStatus = async (silent = false) => { + const refreshUpdateTargets = async (silent = false) => { if (!hasAccess) { + setTestServerStatus(null); setWorkServerStatus(null); if (!silent) { - setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' }); + setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' }); } return null; } @@ -1556,20 +1504,18 @@ export function MainHeader({ setWorkServerStatusLoading(true); try { - const items = await fetchServerCommands(); - const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null; - setWorkServerStatus(nextWorkServerStatus); + const nextStatuses = await refreshServerStatuses(); if (!silent) { - setWorkServerUpdateFeedback(null); + setUpdateCheckFeedback(null); } - return nextWorkServerStatus; + return nextStatuses; } catch (error) { if (!silent) { - setWorkServerUpdateFeedback({ + setUpdateCheckFeedback({ tone: 'error', - message: error instanceof Error ? error.message : '워크서버 업데이트 상태를 불러오지 못했습니다.', + message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.', }); } return null; @@ -1578,94 +1524,165 @@ export function MainHeader({ } }; - const waitForWorkServerRestart = async () => { - for (let attempt = 0; attempt < 12; attempt += 1) { + const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => { + for (let attempt = 0; attempt < 16; attempt += 1) { await waitForDuration(2500); try { - const nextStatus = await refreshWorkServerStatus(true); + const nextStatuses = await refreshServerStatuses(); + const nextStatus = nextStatuses[key]; if (!nextStatus) { continue; } - if (nextStatus.availability === 'online' && !nextStatus.updateAvailable && !nextStatus.buildRequired) { - setWorkServerUpdateFeedback({ - tone: 'success', - message: '워크서버가 재시작되었고 최신 빌드가 적용되었습니다.', - }); - return; - } + const restarted = + baseline == null || + nextStatus.startedAt !== baseline.startedAt || + nextStatus.checkedAt !== baseline.checkedAt; - if (nextStatus.availability === 'online' && nextStatus.buildRequired) { - setWorkServerUpdateFeedback({ - tone: 'info', - message: - nextStatus.updateSummary ?? '워크서버는 재시작되었지만 최신 소스 기준으로 다시 빌드가 필요합니다.', - }); - return; + if (nextStatus.availability === 'online' && restarted) { + return { ok: true, item: nextStatus }; } } catch { - // 서버가 재시작 중이면 일시적으로 실패할 수 있어 다음 주기까지 기다립니다. + // 서버 재기동 중에는 일시적으로 조회가 실패할 수 있습니다. } } - setWorkServerUpdateFeedback({ - tone: 'info', - message: '워크서버 재시작 요청은 접수했습니다. 잠시 후 업데이트 상태를 다시 확인해 주세요.', - }); + return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus }; }; - const handleApplyWorkServerUpdate = async () => { - setWorkServerUpdateCopyFeedback(null); - - if (!hasAccess) { - setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 적용은 권한 토큰 등록 후 사용할 수 있습니다.' }); + const handleResetClientState = async () => { + if (clientResetting) { return; } - if (workServerRestarting || workServerStatusLoading) { - return; - } - - let nextStatus = workServerStatus; - - if (!nextStatus || (!nextStatus.updateAvailable && !nextStatus.buildRequired)) { - nextStatus = await refreshWorkServerStatus(true); - setWorkServerStatus(nextStatus); - } - - if (!nextStatus) { - setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 상태를 먼저 확인해 주세요.' }); - return; - } - - if (!nextStatus.updateAvailable && !nextStatus.buildRequired) { - setWorkServerUpdateFeedback({ tone: 'info', message: '현재 적용할 Work 서버 업데이트가 없습니다.' }); - return; - } - - setWorkServerUpdateFeedback(null); - setWorkServerRestarting(true); + setClientResetCopyFeedback(null); + setClientResetFeedback(null); + setClientResetting(true); try { - const result = await restartServerCommand('work-server'); - setWorkServerStatus(result.item); - setWorkServerUpdateFeedback({ + const result = await resetNonAuthClientState(); + const changedCount = + result.removedLocalStorageKeys.length + + result.removedSessionStorageKeys.length + + result.removedCacheKeys.length + + result.unregisteredServiceWorkerCount; + + setClientResetFeedback({ tone: 'success', message: - result.restartState === 'accepted' - ? '워크서버 재시작 요청을 접수했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.' - : '워크서버를 다시 시작했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.', + changedCount > 0 + ? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.` + : '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.', }); - await waitForWorkServerRestart(); + window.setTimeout(() => { + window.location.replace(window.location.href); + }, 700); } catch (error) { - setWorkServerUpdateFeedback({ + setClientResetFeedback({ tone: 'error', - message: error instanceof Error ? error.message : '워크서버 재시작에 실패했습니다.', + message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.', }); } finally { - setWorkServerRestarting(false); + setClientResetting(false); + } + }; + + 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 result = await restartServerCommand(key); + + if (key === 'test') { + setTestServerStatus(result.item); + } else { + setWorkServerStatus(result.item); + } + + setServerRestartFeedback({ + tone: 'info', + message: + result.restartState === 'accepted' + ? `${targetLabel} 재기동 요청을 접수했습니다. 실제 정상 응답 여부를 확인하는 중입니다.` + : `${targetLabel} 재기동을 실행했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`, + }); + setServerRestartingKey(busyKey); + + const verified = await waitForServerRestart(key, baseline); + + if (!verified.ok || !verified.item || verified.item.availability !== 'online') { + setServerRestartFeedback({ + tone: 'error', + message: `${targetLabel} 재기동 후 정상 응답을 확인하지 못했습니다. 상태를 다시 확인해 주세요.`, + }); + return false; + } + + setServerRestartFeedback({ + tone: 'success', + message: `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`, + }); + return true; + }; + + const handleRestartSingleServer = async (key: 'test' | 'work-server') => { + if (!hasAccess || serverRestartingKey) { + return false; + } + + setServerRestartCopyFeedback(null); + setServerRestartFeedback(null); + setServerRestartingKey(key); + + try { + return await restartServerWithVerification(key, key); + } catch (error) { + const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버'; + setServerRestartFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`, + }); + return false; + } finally { + setServerRestartingKey(null); + } + }; + + const handleRestartBothServers = async () => { + if (!hasAccess || serverRestartingKey) { + return; + } + + setServerRestartCopyFeedback(null); + setServerRestartFeedback(null); + setServerRestartingKey('all'); + + try { + const testOk = await restartServerWithVerification('test', 'all'); + + if (!testOk) { + return; + } + + const workServerOk = await restartServerWithVerification('work-server', 'all'); + + if (!workServerOk) { + return; + } + + setServerRestartFeedback({ + tone: 'success', + message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.', + }); + } catch (error) { + setServerRestartFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.', + }); + } finally { + setServerRestartingKey(null); } }; @@ -2691,11 +2708,8 @@ export function MainHeader({ }} > - {appUpdateStatus === 'updating' ? : } -
@@ -3116,110 +3127,111 @@ export function MainHeader({ ) : null} {activeSettingsModal === 'update' ? ( - 앱 업데이트 - 앱 업데이트 상태: {appUpdateSupported ? getAppUpdateStatusLabel(appUpdateStatus) : '미지원'} - {import.meta.env.DEV ? ( - - 개발 모드에서는 실시간 반영 대신 변경 감지 후 수동 업데이트 버튼으로 화면에 반영합니다. - - ) : null} - {renderFeedback(appUpdateFeedback, appUpdateCopyFeedback, setAppUpdateCopyFeedback)} - {appUpdateStatus === 'updating' ? ( -
-
- 업데이트 적용 중 - - {appUpdateCurrentTaskLabel ?? '새 버전을 내려받고 적용하는 중입니다. 잠시만 기다려 주세요.'} - -
-
- 진행 작업 - {appUpdateCurrentTaskLabel ?? '새 버전 반영 준비'} -
- -
- ) : null} + 업데이트 확인 + + 테스트 + + + 소스 수정일: {getServerLastSourceChangedDateLabel(testServerStatus)} + + + 워크 + + + 소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)} + + {renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)} - Work 서버 업데이트 + 캐시 / 스토리지 초기화 - 상태: {getWorkServerUpdateStatusLabel(workServerStatus)} + 권한, 앱 설정, 푸시/서비스워커 등록은 유지하고 화면 상태와 앱 캐시만 초기화합니다. + + {renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)} + + + 서버 재기동 - 실행 중 빌드: {formatDateTimeLabel(workServerStatus?.runningBuiltAt ?? null)} + 테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)} - 최신 빌드: {formatDateTimeLabel(workServerStatus?.latestBuiltAt ?? null)} + 워크 마지막 확인: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)} - - 최근 소스 변경: {formatDateTimeLabel(workServerStatus?.latestSourceChangeAt ?? null)} - - {workServerStatus?.latestSourceChangePath ? ( - 변경 기준 파일: {workServerStatus.latestSourceChangePath} - ) : null} {!hasAccess ? ( - 워크서버 업데이트 확인과 적용은 권한 토큰 등록 후 사용할 수 있습니다. + 서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다. ) : null} - {workServerStatus?.updateSummary ? {workServerStatus.updateSummary} : null} - {renderFeedback( - workServerUpdateFeedback, - workServerUpdateCopyFeedback, - setWorkServerUpdateCopyFeedback, - )} + {renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)} +
diff --git a/src/app/main/MainLayout.css b/src/app/main/MainLayout.css index 2967456..40ccb0f 100755 --- a/src/app/main/MainLayout.css +++ b/src/app/main/MainLayout.css @@ -214,6 +214,24 @@ background: #2563eb; } +.app-header__server-version-indicator { + display: inline-flex; + width: 12px; + height: 12px; + border: 2px solid #ffffff; + border-radius: 999px; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.08); +} + +.app-header__server-version-indicator--latest { + color: #2563eb; + background: #2563eb; +} + +.app-header__server-version-indicator--stale { + background: #dc2626; +} + .app-header__settings-label { font-size: 14px; font-weight: 600; @@ -344,6 +362,15 @@ background: rgba(255, 255, 255, 0.98); } +.app-sider--mobile-inline.ant-layout-sider { + width: 100% !important; + max-width: 100%; + flex: 0 0 auto !important; + border-right: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(255, 255, 255, 0.92); +} + .app-sider__inner { display: flex; flex-direction: column; @@ -680,6 +707,11 @@ height: calc(100vh - 52px); } + .app-sider--mobile-inline.ant-layout-sider { + position: static; + height: auto; + } + .app-main-content.ant-layout-content { padding: 0; min-height: calc(100dvh - 52px); diff --git a/src/app/main/MainSidebar.tsx b/src/app/main/MainSidebar.tsx index 5883b0c..89b57b0 100755 --- a/src/app/main/MainSidebar.tsx +++ b/src/app/main/MainSidebar.tsx @@ -1,4 +1,5 @@ import { Layout, Menu, Space, Tag, Typography } from 'antd'; +import type { ItemType } from 'antd/es/menu/interface'; import type { MainSidebarProps } from './types'; const { Sider } = Layout; @@ -9,6 +10,7 @@ export function MainSidebar({ hasAccess, sidebarCollapsed, isMobileViewport, + mobileInline = false, openKeys: controlledOpenKeys, apiMenuItems, docsMenuItems, @@ -30,19 +32,46 @@ export function MainSidebar({ onSelectChatMenu, onSelectPlayMenu, }: MainSidebarProps) { + const handleMenuRouteSelection = (key: string, keyPath: string[]) => { + if (keyPath.includes('docs-group')) { + onSelectDocsMenu(key); + return; + } + + if (keyPath.includes('api-group')) { + onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']); + return; + } + + if (keyPath.includes('plan-group') || keyPath.includes('server-group')) { + onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']); + return; + } + + if (keyPath.includes('codex-live-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) { + onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']); + return; + } + + if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) { + onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']); + } + }; + + const collectItemKeys = (items: ItemType[] | undefined): string[] => + (items ?? []).flatMap((item) => { + if (!item || typeof item !== 'object' || !('key' in item)) { + return []; + } + + const itemKey = typeof item.key === 'string' ? item.key : ''; + const childKeys = 'children' in item ? collectItemKeys(item.children as ItemType[] | undefined) : []; + return itemKey ? [itemKey, ...childKeys] : childKeys; + }); + const effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu; const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis'; - const visibleOpenKeys = sidebarCollapsed - ? [] - : controlledOpenKeys.length - ? controlledOpenKeys - : isDocsGroup - ? !hasAccess - ? ['docs-group'] - : ['docs-group', 'api-group'] - : effectiveTopMenu === 'play' - ? ['play-group', 'play-layout-group'] - : ['plan-group', 'codex-live-group', 'app-log-group', 'chat-manage-group']; + const visibleOpenKeys = sidebarCollapsed ? [] : controlledOpenKeys; const selectedKeys = effectiveTopMenu === 'docs' ? [selectedDocsMenu] @@ -61,13 +90,28 @@ export function MainSidebar({ : effectiveTopMenu === 'play' ? [...(playMenuItems ?? [])] : [...(planMenuItems ?? []), ...(chatMenuItems ?? [])]; + const rootKeys = sidebarItems.flatMap((item) => + item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [], + ); + const childKeyMap = new Map( + sidebarItems.flatMap((item) => { + if (!item || typeof item !== 'object' || !('key' in item) || typeof item.key !== 'string') { + return []; + } + + return [[item.key, new Set(collectItemKeys('children' in item ? (item.children as ItemType[] | undefined) : undefined))]] as const; + }), + ); + const isMobileOverlay = isMobileViewport && !mobileInline; return (
@@ -80,37 +124,30 @@ export function MainSidebar({ { - onOpenKeysChange(keys as string[]); + const nextKeys = keys as string[]; + const rootOpenKeys = nextKeys.filter((key) => rootKeys.includes(key)); + + if (rootOpenKeys.length <= 1) { + onOpenKeysChange(nextKeys); + return; + } + + const activeRootKey = + rootOpenKeys.find((key) => !visibleOpenKeys.includes(key)) ?? rootOpenKeys[rootOpenKeys.length - 1]; + const allowedChildKeys = childKeyMap.get(activeRootKey) ?? new Set(); + onOpenKeysChange(nextKeys.filter((key) => key === activeRootKey || allowedChildKeys.has(key))); }} onClick={({ key, keyPath }) => { - if (keyPath.includes('docs-group')) { - onSelectDocsMenu(key); - return; - } - - if (keyPath.includes('api-group')) { - onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']); - return; - } - - if (keyPath.includes('plan-group') || keyPath.includes('server-group')) { - onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']); - return; - } - - if (keyPath.includes('codex-live-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) { - onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']); - return; - } - - if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) { - onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']); - } + handleMenuRouteSelection(String(key), keyPath as string[]); + }} + onSelect={({ key, keyPath }) => { + handleMenuRouteSelection(String(key), keyPath as string[]); }} />
diff --git a/src/app/main/appConfig.ts b/src/app/main/appConfig.ts index d0f0720..f719afe 100755 --- a/src/app/main/appConfig.ts +++ b/src/app/main/appConfig.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react'; import { appendClientIdHeader } from './clientIdentity'; import { getAutomationNotificationPreferenceTarget } from './notificationIdentity'; -const APP_CONFIG_STORAGE_KEY = 'work-server.app-config'; +export const APP_CONFIG_STORAGE_KEY = 'work-server.app-config'; const APP_CONFIG_EVENT = 'work-server:app-config'; const APP_CONFIG_API_PATH = '/app-config'; const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation'; diff --git a/src/app/main/appMaintenance.ts b/src/app/main/appMaintenance.ts new file mode 100644 index 0000000..f163843 --- /dev/null +++ b/src/app/main/appMaintenance.ts @@ -0,0 +1,88 @@ +import { CLIENT_ID_STORAGE_KEY } from './clientIdentity'; +import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, PWA_NOTIFICATION_TOKEN_STORAGE_KEY } from './notificationIdentity'; +import { TOKEN_ACCESS_STORAGE_KEY } from './tokenAccess'; +import { APP_CONFIG_STORAGE_KEY } from './appConfig'; + +const PRESERVED_LOCAL_STORAGE_KEYS = new Set([ + APP_CONFIG_STORAGE_KEY, + TOKEN_ACCESS_STORAGE_KEY, + CLIENT_ID_STORAGE_KEY, + NOTIFICATION_DEVICE_ID_STORAGE_KEY, + PWA_NOTIFICATION_TOKEN_STORAGE_KEY, +]); + +const APP_LOCAL_STORAGE_PREFIXES = ['work-', 'main-chat-panel:', 'gps-layer:', 'ai-code-app:'] as const; +const APP_SESSION_STORAGE_PREFIXES = ['work-', 'main-chat-panel:', 'gps-layer:', 'ai-code-app.'] as const; + +export type ClientResetSummary = { + removedLocalStorageKeys: string[]; + removedSessionStorageKeys: string[]; + removedCacheKeys: string[]; + unregisteredServiceWorkerCount: number; +}; + +function collectMatchingStorageKeys(storage: Storage, prefixes: readonly string[], preservedKeys: ReadonlySet = new Set()) { + const keys: string[] = []; + + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + + if (!key || preservedKeys.has(key)) { + continue; + } + + if (prefixes.some((prefix) => key.startsWith(prefix))) { + keys.push(key); + } + } + + return keys; +} + +async function clearBrowserCaches() { + if (typeof window === 'undefined' || !('caches' in window)) { + return [] as string[]; + } + + try { + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey).catch(() => false))); + return cacheKeys; + } catch { + return [] as string[]; + } +} + +export async function resetNonAuthClientState() { + if (typeof window === 'undefined') { + return { + removedLocalStorageKeys: [], + removedSessionStorageKeys: [], + removedCacheKeys: [], + unregisteredServiceWorkerCount: 0, + } satisfies ClientResetSummary; + } + + const removedLocalStorageKeys = collectMatchingStorageKeys( + window.localStorage, + APP_LOCAL_STORAGE_PREFIXES, + PRESERVED_LOCAL_STORAGE_KEYS, + ); + const removedSessionStorageKeys = collectMatchingStorageKeys(window.sessionStorage, APP_SESSION_STORAGE_PREFIXES); + + removedLocalStorageKeys.forEach((key) => { + window.localStorage.removeItem(key); + }); + removedSessionStorageKeys.forEach((key) => { + window.sessionStorage.removeItem(key); + }); + + const removedCacheKeys = await clearBrowserCaches(); + + return { + removedLocalStorageKeys, + removedSessionStorageKeys, + removedCacheKeys, + unregisteredServiceWorkerCount: 0, + } satisfies ClientResetSummary; +} diff --git a/src/app/main/appUpdate.ts b/src/app/main/appUpdate.ts index 531d7c3..934f54c 100755 --- a/src/app/main/appUpdate.ts +++ b/src/app/main/appUpdate.ts @@ -1,5 +1,3 @@ -import { registerSW } from 'virtual:pwa-register'; - export type AppUpdateStatus = 'idle' | 'available' | 'updating' | 'ready'; export type AppUpdateSnapshot = { @@ -342,27 +340,42 @@ export function initializeAppUpdate() { return; } - serviceWorkerUpdater = registerSW({ - immediate: true, - onNeedRefresh() { - snapshot = { - supported: true, - status: 'available', - progressPercent: null, - currentTaskLabel: null, - }; - emit(); - }, - onRegistered() { - snapshot = { - supported: true, - status: 'ready', - progressPercent: null, - currentTaskLabel: null, - }; - emit(); - }, - onRegisterError() { + emit(); + + void import('virtual:pwa-register') + .then(({ registerSW }) => { + serviceWorkerUpdater = registerSW({ + immediate: true, + onNeedRefresh() { + snapshot = { + supported: true, + status: 'available', + progressPercent: null, + currentTaskLabel: null, + }; + emit(); + }, + onRegistered() { + snapshot = { + supported: true, + status: 'ready', + progressPercent: null, + currentTaskLabel: null, + }; + emit(); + }, + onRegisterError() { + snapshot = { + supported: isAppUpdateSupported(), + status: isAppUpdateSupported() ? 'ready' : 'idle', + progressPercent: null, + currentTaskLabel: null, + }; + emit(); + }, + }); + }) + .catch(() => { snapshot = { supported: isAppUpdateSupported(), status: isAppUpdateSupported() ? 'ready' : 'idle', @@ -370,10 +383,7 @@ export function initializeAppUpdate() { currentTaskLabel: null, }; emit(); - }, - }); - - emit(); + }); } export function subscribeAppUpdate(listener: Listener) { diff --git a/src/app/main/chatTypeAccess.ts b/src/app/main/chatTypeAccess.ts index d87aa63..8a0aabb 100755 --- a/src/app/main/chatTypeAccess.ts +++ b/src/app/main/chatTypeAccess.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { appendClientIdHeader } from './clientIdentity'; export type ChatPermissionRole = 'guest' | 'token-user'; @@ -21,8 +22,12 @@ export type ChatTypeInput = { enabled?: boolean; }; -const CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types'; +const CHAT_TYPES_API_PATH = '/chat-types'; const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed'; +const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000; +const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types'; +const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids'; +const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids'; export const CHAT_PERMISSION_ROLE_LABELS: Record = { guest: '게스트', @@ -33,11 +38,12 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ { id: 'general-request', name: '일반 요청', - description: '일반 Codex Live 요청입니다. 현재 로컬 main 작업본 기준으로 바로 확인하고 필요 시 소스를 수정합니다.', + description: + '현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.', isTemplate: false, permissions: ['token-user'], enabled: true, - updatedAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', }, { id: 'api-request-template', @@ -54,6 +60,10 @@ function normalizeText(value: string | null | undefined) { return value?.trim() ?? ''; } +function buildChatTypeNameKey(value: string | null | undefined) { + return normalizeText(value).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +} + function normalizePermissions(permissions: ChatPermissionRole[] | null | undefined): ChatPermissionRole[] { const nextPermissions = Array.from( new Set( @@ -87,75 +97,216 @@ function normalizeChatType(record: Partial): ChatTypeRecord | nu }; } -function ensureDefaultChatTypes(chatTypes: ChatTypeRecord[]) { - const defaultsById = new Map(DEFAULT_CHAT_TYPES.map((item) => [item.id, item])); - const merged = chatTypes.map((item) => { - const defaultItem = defaultsById.get(item.id); - - if (!defaultItem) { - return item; - } - - const storedUpdatedAt = Date.parse(item.updatedAt); - const defaultUpdatedAt = Date.parse(defaultItem.updatedAt); - - if (Number.isFinite(storedUpdatedAt) && Number.isFinite(defaultUpdatedAt) && storedUpdatedAt >= defaultUpdatedAt) { - return item; - } - - return { - ...item, - ...defaultItem, - }; - }); - - const existingIds = new Set(merged.map((item) => item.id)); - const missingDefaults = DEFAULT_CHAT_TYPES.filter((item) => !existingIds.has(item.id)); - - return [...merged, ...missingDefaults].sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); +function getChatTypeSemanticKey(record: Pick) { + return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`; } -export function loadChatTypes() { +function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) { + const leftTime = Date.parse(left.updatedAt); + const rightTime = Date.parse(right.updatedAt); + + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + + return 0; +} + +function dedupeChatTypes(chatTypes: ChatTypeRecord[]) { + const bySemanticKey = new Map(); + + for (const item of chatTypes) { + const semanticKey = getChatTypeSemanticKey(item); + const current = bySemanticKey.get(semanticKey); + + if (!current) { + bySemanticKey.set(semanticKey, item); + continue; + } + + bySemanticKey.set(semanticKey, compareUpdatedAt(current, item) <= 0 ? item : current); + } + + return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); +} + +function sanitizeChatTypes(chatTypes: Partial[]) { + return dedupeChatTypes( + chatTypes + .map((item) => normalizeChatType(item)) + .filter((item): item is ChatTypeRecord => Boolean(item)), + ); +} + +function emitChatTypesChange() { if (typeof window === 'undefined') { - return DEFAULT_CHAT_TYPES; + return; + } + + window.dispatchEvent(new Event(CHAT_TYPE_SYNC_EVENT)); +} + +function resolveChatTypesApiBaseUrl() { + if (import.meta.env.VITE_WORK_SERVER_URL) { + return import.meta.env.VITE_WORK_SERVER_URL; + } + + return '/api'; +} + +function resolveChatTypesFallbackBaseUrl() { + if (typeof window === 'undefined') { + return null; + } + + const hostname = window.location.hostname; + + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) { + return null; + } + + const fallbackUrl = new URL(window.location.origin); + fallbackUrl.port = '3100'; + fallbackUrl.pathname = '/api'; + fallbackUrl.search = ''; + fallbackUrl.hash = ''; + + return fallbackUrl.toString().replace(/\/+$/, ''); +} + +const CHAT_TYPES_API_BASE_URL = resolveChatTypesApiBaseUrl(); +const CHAT_TYPES_FALLBACK_BASE_URL = resolveChatTypesFallbackBaseUrl(); + +async function requestChatTypesOnce(baseUrl: string, init?: RequestInit) { + const headers = appendClientIdHeader(init?.headers); + const hasBody = init?.body !== undefined && init.body !== null; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), CHAT_TYPE_REQUEST_TIMEOUT_MS); + + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); } try { - const raw = window.localStorage.getItem(CHAT_TYPE_STORAGE_KEY); + const response = await fetch(`${baseUrl}${CHAT_TYPES_API_PATH}`, { + ...init, + headers, + signal: controller.signal, + cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined), + }); + + if (!response.ok) { + throw new Error(await response.text() || '채팅유형 요청에 실패했습니다.'); + } + + return response.json() as Promise; + } finally { + window.clearTimeout(timeoutId); + } +} + +async function requestChatTypes(init?: RequestInit) { + try { + return await requestChatTypesOnce(CHAT_TYPES_API_BASE_URL, init); + } catch (error) { + const shouldRetryWithFallback = + CHAT_TYPES_FALLBACK_BASE_URL && + CHAT_TYPES_FALLBACK_BASE_URL !== CHAT_TYPES_API_BASE_URL && + error instanceof Error && + /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message); + + if (!shouldRetryWithFallback) { + throw error; + } + + return requestChatTypesOnce(CHAT_TYPES_FALLBACK_BASE_URL, init); + } +} + +function readLegacyDeletedChatTypeIds() { + if (typeof window === 'undefined') { + return new Set(); + } + + try { + const rawDeletedIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY); + const rawLegacyDeletedDefaultIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY); + const deletedIds = [rawDeletedIds, rawLegacyDeletedDefaultIds] + .flatMap((raw) => { + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed : []; + }) + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); + + return new Set(deletedIds); + } catch { + return new Set(); + } +} + +function readLegacyChatTypes() { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY); if (!raw) { - return DEFAULT_CHAT_TYPES; + return null; } const parsed = JSON.parse(raw) as Partial[]; if (!Array.isArray(parsed)) { - return DEFAULT_CHAT_TYPES; + return null; } - const normalized = parsed - .map((item) => normalizeChatType(item)) - .filter((item): item is ChatTypeRecord => Boolean(item)); - - const resolved = normalized.length > 0 ? ensureDefaultChatTypes(normalized) : DEFAULT_CHAT_TYPES; - - if (JSON.stringify(resolved) !== JSON.stringify(normalized)) { - window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(resolved)); - } - - return resolved; + const deletedIds = readLegacyDeletedChatTypeIds(); + const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id)); + return normalized; } catch { - return DEFAULT_CHAT_TYPES; + return null; } } -export function saveChatTypes(chatTypes: ChatTypeRecord[]) { +function clearLegacyChatTypeStorage() { if (typeof window === 'undefined') { return; } - window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(chatTypes)); - window.dispatchEvent(new CustomEvent(CHAT_TYPE_SYNC_EVENT)); + window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY); + window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY); + window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY); +} + +async function fetchChatTypesFromServer() { + const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial[] | null }>({ + method: 'GET', + }); + + if (response.chatTypes == null) { + return null; + } + + return sanitizeChatTypes(response.chatTypes); +} + +async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) { + const resolved = sanitizeChatTypes(chatTypes); + const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial[] }>({ + method: 'PUT', + body: JSON.stringify({ chatTypes: resolved }), + }); + + emitChatTypesChange(); + clearLegacyChatTypeStorage(); + return sanitizeChatTypes(response.chatTypes); } export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) { @@ -170,13 +321,25 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput }); if (!nextRecord) { - return chatTypes; + return sanitizeChatTypes(chatTypes); } - const nextChatTypes = chatTypes.filter((item) => item.id !== nextRecord.id); + const nextSemanticKey = getChatTypeSemanticKey(nextRecord); + const nextChatTypes = chatTypes.filter( + (item) => item.id !== nextRecord.id && getChatTypeSemanticKey(item) !== nextSemanticKey, + ); nextChatTypes.push(nextRecord); - nextChatTypes.sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); - return nextChatTypes; + return sanitizeChatTypes(nextChatTypes); +} + +export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string) { + const normalizedId = normalizeText(chatTypeId); + + if (!normalizedId) { + return sanitizeChatTypes(chatTypes); + } + + return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId)); } export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] { @@ -188,31 +351,68 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo } export function useChatTypeRegistry() { - const [chatTypes, setChatTypes] = useState(() => loadChatTypes()); + const [chatTypes, setChatTypesState] = useState(DEFAULT_CHAT_TYPES); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const isMountedRef = useRef(true); useEffect(() => { - if (typeof window === 'undefined') { - return undefined; - } + isMountedRef.current = true; - const syncChatTypes = () => { - setChatTypes(loadChatTypes()); + const syncChatTypes = async () => { + setIsLoading(true); + setErrorMessage(''); + + try { + const serverChatTypes = await fetchChatTypesFromServer(); + let resolvedChatTypes = serverChatTypes; + + if (resolvedChatTypes == null) { + const legacyChatTypes = readLegacyChatTypes(); + resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES; + resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes); + } + + if (isMountedRef.current) { + setChatTypesState(resolvedChatTypes); + } + } catch (error) { + if (isMountedRef.current) { + setChatTypesState(DEFAULT_CHAT_TYPES); + setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.'); + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } }; - window.addEventListener('storage', syncChatTypes); - window.addEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes); + void syncChatTypes(); + + const handleSync = () => { + void syncChatTypes(); + }; + + window.addEventListener(CHAT_TYPE_SYNC_EVENT, handleSync); return () => { - window.removeEventListener('storage', syncChatTypes); - window.removeEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes); + isMountedRef.current = false; + window.removeEventListener(CHAT_TYPE_SYNC_EVENT, handleSync); }; }, []); return { chatTypes, - setChatTypes: (nextChatTypes: ChatTypeRecord[]) => { - saveChatTypes(nextChatTypes); - setChatTypes(nextChatTypes); + isLoading, + errorMessage, + setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => { + const resolved = await saveChatTypesToServer(nextChatTypes); + if (isMountedRef.current) { + setChatTypesState(resolved); + setErrorMessage(''); + } + return resolved; }, }; } diff --git a/src/app/main/chatV2/components/ConversationRoomPane.tsx b/src/app/main/chatV2/components/ConversationRoomPane.tsx index 012cf4a..016a437 100644 --- a/src/app/main/chatV2/components/ConversationRoomPane.tsx +++ b/src/app/main/chatV2/components/ConversationRoomPane.tsx @@ -1,4 +1,25 @@ -import { Empty, Spin, Typography } from 'antd'; +import { + CodeOutlined, + CopyOutlined, + DownloadOutlined, + DownOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + UpOutlined, +} from '@ant-design/icons'; +import { Button, Empty, Spin, Typography, message as antdMessage } from 'antd'; +import { useEffect, useState, type ReactNode } from 'react'; +import { InlineImage } from '../../../../components/common/InlineImage'; +import { CodexDiffBlock } from '../../../../components/previewer'; +import { + ChatPreviewBody, + resolveChatPreviewGlyph, + resolveChatPreviewKindLabel, + type ChatPreviewKind, + type ChatPreviewTarget, +} from '../../mainChatPanel/ChatPreviewBody'; +import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl'; +import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils'; import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types'; const { Text } = Typography; @@ -12,6 +33,460 @@ type ConversationRoomPaneProps = { errorMessage: string; }; +const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; +const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; +const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g; +const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; +const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; +const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; + +type MessageRenderPayload = { + visibleText: string; + diffBlocks: string[]; +}; + +function formatChatTimestamp(timestamp: string) { + const normalized = String(timestamp ?? '').trim(); + + if (!normalized) { + return ''; + } + + const parsed = new Date(normalized); + + if (Number.isNaN(parsed.getTime())) { + return normalized; + } + + return new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + .format(parsed) + .replace(',', ''); +} + +function classifyInlinePreviewKind(url: string): ChatPreviewKind | 'file' { + const pathname = url.toLowerCase().split('?')[0] ?? ''; + + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { + return 'image'; + } + + if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) { + return 'video'; + } + + if (/\.(md|markdown)$/i.test(pathname)) { + return 'markdown'; + } + + if (/\.(diff|patch)$/i.test(pathname)) { + return 'diff'; + } + + if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { + return 'code'; + } + + if (/\.(txt|log|csv)$/i.test(pathname)) { + return 'document'; + } + + if (/\.pdf$/i.test(pathname)) { + return 'pdf'; + } + + return 'file'; +} + +function buildInlinePreviewLabel(url: string) { + try { + const parsed = new URL(url); + return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname; + } catch { + return url; + } +} + +function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') { + if (typeof document === 'undefined') { + return; + } + + const blob = new Blob([content], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); +} + +function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] { + const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? []; + const seen = new Set(); + const targets: ChatPreviewTarget[] = []; + + for (const matchedUrl of matches) { + const normalizedUrl = normalizeChatResourceUrl(matchedUrl); + const kind = classifyInlinePreviewKind(normalizedUrl); + + if (kind === 'file' || seen.has(normalizedUrl)) { + continue; + } + + seen.add(normalizedUrl); + targets.push({ + url: normalizedUrl, + label: buildInlinePreviewLabel(normalizedUrl), + kind, + }); + } + + return targets; +} + +function renderMessageInlineParts(line: string): ReactNode[] { + const renderedParts: ReactNode[] = []; + let cursor = 0; + + for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) { + const [fullMatch, label, rawHref] = match; + const start = match.index ?? 0; + + if (start > cursor) { + renderedParts.push(line.slice(cursor, start)); + } + + const href = normalizeChatResourceUrl(rawHref.trim()); + renderedParts.push( + + {label.trim() || href} + , + ); + cursor = start + fullMatch.length; + } + + if (cursor < line.length) { + renderedParts.push(line.slice(cursor)); + } + + return renderedParts.length > 0 ? renderedParts : [line]; +} + +function renderMessageBody(text: string) { + return text.split('\n').map((line, index) => { + const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN); + + if (imageMatch) { + const [, alt, rawSrc] = imageMatch; + const src = normalizeChatResourceUrl(rawSrc.trim()); + + return ( +
+ +
+ ); + } + + if (!line.length) { + return ) : ( - messages.map((message) => ( -
-
- {message.author} - {message.timestamp} + messages.map((message) => { + const canCollapseMessage = isLikelyCollapsibleMessage(message.text); + 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 inlinePreviewTargets = extractInlinePreviewTargets(visibleText); + const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; + const shouldRenderStandalonePreview = + hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); + const stackClassName = [ + `app-chat-message-stack app-chat-message-stack--${message.author}`, + shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
+ {shouldRenderStandalonePreview ? null : ( +
+
+
+ {message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'} + {formatChatTimestamp(message.timestamp)} +
+ {message.author !== 'system' ? ( +
+
{visibleText ? renderMessageBody(visibleText) : null}
+ {canCollapseMessage ? ( + + ) : null} +
+ )} + {hasPreviewCards ? ( +
+ {diffBlocks.map((diffText, index) => { + const previewKey = `${message.id}-diff-${index}`; + + return ( + { + setExpandedPreviewKey((current) => { + if (fullscreenPreviewKey === previewKey) { + setFullscreenPreviewKey(null); + return null; + } + + return current === previewKey ? null : previewKey; + }); + }} + onToggleFullscreen={() => { + setFullscreenPreviewKey((current) => { + const nextKey = current === previewKey ? null : previewKey; + + if (nextKey) { + setExpandedPreviewKey(previewKey); + } + + return nextKey; + }); + }} + /> + ); + })} + {inlinePreviewTargets.map((target) => { + const previewKey = `${message.id}-${target.url}`; + return ( + { + setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey)); + }} + /> + ); + })} +
+ ) : null}
-
{message.text}
-
- )) + ); + }) )}
diff --git a/src/app/main/chatV2/data/chatGateway.ts b/src/app/main/chatV2/data/chatGateway.ts index 3610581..046d2ee 100644 --- a/src/app/main/chatV2/data/chatGateway.ts +++ b/src/app/main/chatV2/data/chatGateway.ts @@ -35,6 +35,7 @@ export type ChatGateway = { createConversation: (args: { sessionId: string; title: string; + chatTypeId?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; @@ -43,7 +44,7 @@ export type ChatGateway = { updateConversation: ( sessionId: string, payload: Partial< - Pick + Pick >, ) => Promise; deleteConversation: (sessionId: string) => Promise; diff --git a/src/app/main/chatV2/hooks/useConversationListData.ts b/src/app/main/chatV2/hooks/useConversationListData.ts index a939282..18de40b 100644 --- a/src/app/main/chatV2/hooks/useConversationListData.ts +++ b/src/app/main/chatV2/hooks/useConversationListData.ts @@ -1,9 +1,6 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { sortChatConversationSummaries } from '../../mainChatPanel'; import type { ChatConversationSummary } from '../../mainChatPanel/types'; -import { - CHAT_CONVERSATIONS_UPDATED_EVENT, - readChatConversationsUpdatedEvent, -} from '../data/chatClientEvents'; import { chatGateway } from '../data/chatGateway'; type UseConversationListDataOptions = { @@ -19,6 +16,33 @@ type UseConversationListDataResult = { setConversationSearch: Dispatch>; }; +function mergeConversationItemsPreservingRequestedSession( + nextItems: ChatConversationSummary[], + previousItems: ChatConversationSummary[], + requestedSessionId: string, +) { + const normalizedRequestedSessionId = requestedSessionId.trim(); + + if (!normalizedRequestedSessionId) { + return sortChatConversationSummaries(nextItems); + } + + const hasRequestedSession = nextItems.some((item) => item.sessionId === normalizedRequestedSessionId); + + if (hasRequestedSession) { + return sortChatConversationSummaries(nextItems); + } + + const preservedRequestedSession = + previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null; + + if (!preservedRequestedSession) { + return sortChatConversationSummaries(nextItems); + } + + return sortChatConversationSummaries([preservedRequestedSession, ...nextItems]); +} + export function useConversationListData({ requestedSessionId, }: UseConversationListDataOptions): UseConversationListDataResult { @@ -31,9 +55,11 @@ export function useConversationListData({ try { const items = await chatGateway.listConversations(); - setConversationItems(items); + setConversationItems((previous) => + mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId), + ); } catch { - setConversationItems([]); + setConversationItems((previous) => previous); } finally { setIsConversationListLoading(false); } @@ -46,12 +72,14 @@ export function useConversationListData({ .listConversations() .then((items) => { if (!isCancelled) { - setConversationItems(items); + setConversationItems((previous) => + mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId), + ); } }) .catch(() => { if (!isCancelled) { - setConversationItems([]); + setConversationItems((previous) => previous); } }) .finally(() => { @@ -67,63 +95,6 @@ export function useConversationListData({ }; }, []); - useEffect(() => { - if (!requestedSessionId || isConversationListLoading) { - return; - } - - if (conversationItems.some((item) => item.sessionId === requestedSessionId)) { - return; - } - - let isCancelled = false; - - const loadRequestedConversation = async () => { - try { - const response = await chatGateway.getConversationDetail(requestedSessionId); - - if (isCancelled || response.item.sessionId !== requestedSessionId) { - return; - } - - setConversationItems((previous) => { - const exists = previous.some((item) => item.sessionId === response.item.sessionId); - if (!exists) { - return [response.item, ...previous]; - } - - return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)); - }); - } catch { - // 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다. - } - }; - - void loadRequestedConversation(); - - return () => { - isCancelled = true; - }; - }, [conversationItems, isConversationListLoading, requestedSessionId]); - - useEffect(() => { - const handleConversationsUpdated = (event: Event) => { - const detail = readChatConversationsUpdatedEvent(event); - - if (!detail) { - return; - } - - setConversationItems(detail.items); - }; - - window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener); - - return () => { - window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener); - }; - }, []); - return { conversationItems, setConversationItems, diff --git a/src/app/main/chatV2/hooks/useConversationRoomData.ts b/src/app/main/chatV2/hooks/useConversationRoomData.ts index 9ac8a47..c47d07e 100644 --- a/src/app/main/chatV2/hooks/useConversationRoomData.ts +++ b/src/app/main/chatV2/hooks/useConversationRoomData.ts @@ -1,5 +1,6 @@ -import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils'; +import { sortChatConversationSummaries } from '../../mainChatPanel'; import { chatGateway } from '../data/chatGateway'; import type { ChatConversationRequest, @@ -7,39 +8,28 @@ import type { ChatMessage, } from '../../mainChatPanel/types'; -const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3; -const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20; +const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6; +const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6; +const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800]; -function getCachedSessionMessages(cache: Map, sessionId: string) { - const normalizedSessionId = sessionId.trim(); - - if (!normalizedSessionId) { - return [] as ChatMessage[]; - } - - return cache.get(normalizedSessionId) ?? []; -} - -function getBestAvailableSessionMessages( - cache: Map, +function mergeConversationRequests( + previous: ChatConversationRequest[], + incoming: ChatConversationRequest[], sessionId: string, - currentSessionId: string, - currentMessages: ChatMessage[], ) { - const cachedMessages = getCachedSessionMessages(cache, sessionId); + const previousSessionItems = previous.filter((item) => item.sessionId === sessionId); + const incomingRequestIds = new Set(incoming.map((item) => item.requestId)); + const preservedLocalItems = previousSessionItems.filter((item) => !incomingRequestIds.has(item.requestId)); - if (sessionId !== currentSessionId || currentMessages.length === 0) { - return cachedMessages; - } - - return mergeRecoveredChatMessages(cachedMessages, currentMessages); + return [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } type UseConversationRoomDataOptions = { activeSessionId: string; + oldestLoadedMessageId: number | null; + reloadKey: number; connectionState: 'connecting' | 'connected' | 'disconnected'; - shouldBlockConversationWhileLoading: (sessionId: string) => boolean; - captureViewportRestoreSnapshot: () => void; + captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void; sessionMessageCacheRef: MutableRefObject>; messagesRef: MutableRefObject; pendingViewportRestoreRef: MutableRefObject; @@ -59,8 +49,9 @@ type UseConversationRoomDataOptions = { export function useConversationRoomData({ activeSessionId, + oldestLoadedMessageId, + reloadKey, connectionState, - shouldBlockConversationWhileLoading, captureViewportRestoreSnapshot, sessionMessageCacheRef, messagesRef, @@ -78,8 +69,11 @@ export function useConversationRoomData({ queueViewportPrependRestore, viewportRef, }: UseConversationRoomDataOptions) { + const previousSessionIdRef = useRef(''); + useEffect(() => { if (!activeSessionId.trim()) { + previousSessionIdRef.current = ''; setMessages([]); setRequestItems([]); setIsConversationContentLoading(false); @@ -92,53 +86,95 @@ export function useConversationRoomData({ let isCancelled = false; const requestedSessionId = activeSessionId; + const waitForRetry = (delayMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); + const loadConversationDetail = async () => { - captureViewportRestoreSnapshot(); + const isSessionChanged = previousSessionIdRef.current !== requestedSessionId; + + previousSessionIdRef.current = requestedSessionId; + captureViewportRestoreSnapshot({ + forceStickToBottom: isSessionChanged, + }); pendingViewportRestoreRef.current = true; setConversationLoadingLabel('대화 내용을 불러오는 중입니다.'); - setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId)); + const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []); + + if (cachedMessages.length > 0) { + setMessages(cachedMessages); + } + + setIsConversationContentLoading(true); setIsDeferringAuxiliaryChatRequests(true); try { - const response = await chatGateway.getConversationDetail(requestedSessionId, { - limit: INITIAL_CONVERSATION_MESSAGE_LIMIT, - }); + let response: Awaited> | null = null; + let lastError: unknown = null; + + for (const delayMs of CONVERSATION_DETAIL_RETRY_DELAYS_MS) { + if (delayMs > 0) { + await waitForRetry(delayMs); + } + + if (isCancelled) { + return; + } + + try { + response = await chatGateway.getConversationDetail(requestedSessionId, { + limit: INITIAL_CONVERSATION_REQUEST_PAGE_SIZE, + }); + break; + } catch (error) { + lastError = error; + } + } + + if (!response) { + throw lastError ?? new Error('대화 내용을 불러오지 못했습니다.'); + } if (!isCancelled && response.item.sessionId === requestedSessionId) { setConversationItems((previous) => { const exists = previous.some((item) => item.sessionId === response.item.sessionId); if (!exists) { - return [response.item, ...previous]; + return sortChatConversationSummaries([response.item, ...previous]); } - return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)); + return sortChatConversationSummaries( + previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)), + ); }); - const baseMessages = getBestAvailableSessionMessages( - sessionMessageCacheRef.current, - requestedSessionId, - activeSessionId, - messagesRef.current, - ); - const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages); - sessionMessageCacheRef.current.set(requestedSessionId, nextMessages); - setMessages(nextMessages); - setRequestItems(response.requests); + const baseMessages = + isSessionChanged + ? [] + : requestedSessionId === activeSessionId + ? messagesRef.current + : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []); + const mergedMessages = mergeRecoveredChatMessages(baseMessages, response.messages); + + sessionMessageCacheRef.current.set(requestedSessionId, mergedMessages); + setMessages(mergedMessages); + setRequestItems((previous) => { + const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId); + return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)]; + }); setHasOlderMessages(response.hasOlderMessages); setOldestLoadedMessageId(response.oldestLoadedMessageId); } } catch { if (!isCancelled) { - const cachedMessages = getBestAvailableSessionMessages( - sessionMessageCacheRef.current, - requestedSessionId, - activeSessionId, - messagesRef.current, + setMessages(cachedMessages); + setHasOlderMessages(false); + setOldestLoadedMessageId(cachedMessages[0]?.id ?? null); + setConversationLoadingLabel( + cachedMessages.length > 0 + ? '저장된 대화 내용을 먼저 보여주고 있습니다. 서버 연결을 다시 확인해 주세요.' + : '대화 내용을 다시 불러오지 못했습니다.', ); - - if (cachedMessages.length > 0) { - setMessages(cachedMessages); - } } } finally { if (!isCancelled) { @@ -158,6 +194,7 @@ export function useConversationRoomData({ captureViewportRestoreSnapshot, messagesRef, pendingViewportRestoreRef, + reloadKey, sessionMessageCacheRef, setConversationItems, setConversationLoadingLabel, @@ -167,105 +204,17 @@ export function useConversationRoomData({ setMessages, setOldestLoadedMessageId, setRequestItems, - shouldBlockConversationWhileLoading, ]); useEffect(() => { - if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) { - return; + if (connectionState === 'connected') { + shouldRestoreConversationAfterReconnectRef.current = false; } - - let isCancelled = false; - const requestedSessionId = activeSessionId; - - const restoreConversationAfterReconnect = async () => { - setIsDeferringAuxiliaryChatRequests(true); - - try { - const response = await chatGateway.getConversationDetail(requestedSessionId, { - limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0), - }); - - if (!isCancelled && response.item.sessionId === requestedSessionId) { - const baseMessages = getBestAvailableSessionMessages( - sessionMessageCacheRef.current, - requestedSessionId, - activeSessionId, - messagesRef.current, - ); - const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages); - const hasMessageDiff = nextMessages !== baseMessages; - - if (hasMessageDiff) { - captureViewportRestoreSnapshot(); - pendingViewportRestoreRef.current = true; - setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.'); - setIsConversationContentLoading(true); - } - - setConversationItems((previous) => { - const exists = previous.some((item) => item.sessionId === response.item.sessionId); - if (!exists) { - return [response.item, ...previous]; - } - - return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)); - }); - setRequestItems(response.requests); - setHasOlderMessages(response.hasOlderMessages); - setOldestLoadedMessageId(response.oldestLoadedMessageId); - - if (hasMessageDiff) { - sessionMessageCacheRef.current.set(requestedSessionId, nextMessages); - setMessages(nextMessages); - window.requestAnimationFrame(() => { - if (!isCancelled) { - setIsConversationContentLoading(false); - } - }); - } - } - } catch { - if (!isCancelled) { - setIsConversationContentLoading(false); - } - } finally { - if (!isCancelled) { - shouldRestoreConversationAfterReconnectRef.current = false; - if (!pendingViewportRestoreRef.current) { - setIsConversationContentLoading(false); - } - setIsDeferringAuxiliaryChatRequests(false); - } - } - }; - - void restoreConversationAfterReconnect(); - - return () => { - isCancelled = true; - }; - }, [ - activeSessionId, - captureViewportRestoreSnapshot, - connectionState, - messagesRef, - pendingViewportRestoreRef, - sessionMessageCacheRef, - setConversationItems, - setConversationLoadingLabel, - setIsConversationContentLoading, - setIsDeferringAuxiliaryChatRequests, - setHasOlderMessages, - setMessages, - setOldestLoadedMessageId, - setRequestItems, - shouldRestoreConversationAfterReconnectRef, - ]); + }, [connectionState, shouldRestoreConversationAfterReconnectRef]); const loadOlderMessages = async () => { const requestedSessionId = activeSessionId.trim(); - const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null; + const oldestVisibleMessageId = oldestLoadedMessageId; if (!requestedSessionId || oldestVisibleMessageId == null) { return; @@ -275,11 +224,14 @@ export function useConversationRoomData({ try { const response = await chatGateway.getConversationDetail(requestedSessionId, { - limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE, + limit: OLDER_CONVERSATION_REQUEST_PAGE_SIZE, beforeMessageId: oldestVisibleMessageId, }); - if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) { + if ( + response.item.sessionId !== requestedSessionId || + (response.messages.length === 0 && response.requests.length === 0) + ) { setHasOlderMessages(response.hasOlderMessages); setOldestLoadedMessageId(response.oldestLoadedMessageId); return; @@ -293,7 +245,10 @@ export function useConversationRoomData({ queueViewportPrependRestore(previousScrollHeight, previousScrollTop); sessionMessageCacheRef.current.set(requestedSessionId, nextMessages); setMessages(nextMessages); - setRequestItems(response.requests); + setRequestItems((previous) => { + const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId); + return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)]; + }); setHasOlderMessages(response.hasOlderMessages); setOldestLoadedMessageId(response.oldestLoadedMessageId); } finally { diff --git a/src/app/main/chatV2/hooks/useConversationViewController.ts b/src/app/main/chatV2/hooks/useConversationViewController.ts index 9dfc517..c778113 100644 --- a/src/app/main/chatV2/hooks/useConversationViewController.ts +++ b/src/app/main/chatV2/hooks/useConversationViewController.ts @@ -15,7 +15,6 @@ type UseConversationViewControllerOptions = { previewItems: PreviewItem[]; selectedChatTypeId: string | null; composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null }; - sessionMessageCacheRef: { current: Map }; setActiveSystemStatus: (value: string | null) => void; setComposerAttachments: React.Dispatch>; setCopiedMessageId: (value: number | null) => void; @@ -31,7 +30,6 @@ export function useConversationViewController({ composerRef, previewItems, selectedChatTypeId, - sessionMessageCacheRef, setActiveSystemStatus, setComposerAttachments, setCopiedMessageId, @@ -59,7 +57,7 @@ export function useConversationViewController({ previousSessionIdRef.current = activeSessionId; - setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []); + setMessages([]); setDraft(''); setComposerAttachments([]); setCopiedMessageId(null); @@ -70,7 +68,6 @@ export function useConversationViewController({ setIsResourceStripOpen(false); }, [ activeSessionId, - sessionMessageCacheRef, setActiveSystemStatus, setComposerAttachments, setCopiedMessageId, diff --git a/src/app/main/chatV2/hooks/useConversationViewportController.ts b/src/app/main/chatV2/hooks/useConversationViewportController.ts index 20b0a93..46638a0 100644 --- a/src/app/main/chatV2/hooks/useConversationViewportController.ts +++ b/src/app/main/chatV2/hooks/useConversationViewportController.ts @@ -132,7 +132,16 @@ export function useConversationViewportController({ setShowScrollToBottom(!isNearBottom); }, [viewportRef]); - const captureViewportRestoreSnapshot = useCallback(() => { + const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => { + if (options?.forceStickToBottom) { + viewportRestoreSnapshotRef.current = { + shouldStickToBottom: true, + offsetFromBottom: 0, + }; + shouldStickToBottomRef.current = true; + return; + } + const viewport = viewportRef.current; if (!viewport) { diff --git a/src/app/main/chatV2/hooks/useNotificationCenterData.ts b/src/app/main/chatV2/hooks/useNotificationCenterData.ts index 3f9ec9c..22532e5 100644 --- a/src/app/main/chatV2/hooks/useNotificationCenterData.ts +++ b/src/app/main/chatV2/hooks/useNotificationCenterData.ts @@ -6,6 +6,7 @@ import { fetchNotificationMessages, updateNotificationMessageReadState, type NotificationMessageItem, + type NotificationMessageListStatus, } from '../../notificationApi'; import { useUnreadCounts } from './useUnreadCounts'; @@ -20,6 +21,7 @@ function mergeMessageItem(items: NotificationMessageItem[], nextItem: Notificati } export function useNotificationCenterData(drawerOpen: boolean) { + const [listStatus, setListStatus] = useState('unread'); const [detailOpen, setDetailOpen] = useState(false); const [messages, setMessages] = useState([]); const [selectedMessage, setSelectedMessage] = useState(null); @@ -40,7 +42,7 @@ export function useNotificationCenterData(drawerOpen: boolean) { setListError(null); try { - const response = await fetchNotificationMessages({ limit: 30 }); + const response = await fetchNotificationMessages({ status: listStatus, limit: 30 }); setMessages(response.items); } catch (error) { setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.'); @@ -53,7 +55,10 @@ export function useNotificationCenterData(drawerOpen: boolean) { setSelectedMessage(nextItem); setMessages((current) => { const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false; - const nextItems = mergeMessageItem(current, nextItem); + const nextItems = + listStatus === 'unread' && nextItem.read + ? current.filter((item) => item.id !== nextItem.id) + : mergeMessageItem(current, nextItem); if (wasUnread !== nextItem.read) { void refreshNotificationUnreadCount(); @@ -149,9 +154,11 @@ export function useNotificationCenterData(drawerOpen: boolean) { } void loadMessages(); - }, [drawerOpen]); + }, [drawerOpen, listStatus]); return { + listStatus, + setListStatus, unreadCount, detailOpen, setDetailOpen, diff --git a/src/app/main/layout/MainLayout.tsx b/src/app/main/layout/MainLayout.tsx index e808751..674085b 100755 --- a/src/app/main/layout/MainLayout.tsx +++ b/src/app/main/layout/MainLayout.tsx @@ -4,8 +4,7 @@ import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router- import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer'; import { useAppStore } from '../../../store'; import { useTokenAccess } from '../tokenAccess'; -import { useAppConfig } from '../appConfig'; -import { ChatNotificationBridgeV2 } from '../ChatNotificationBridgeV2'; +import { syncAppConfigFromServer, useAppConfig } from '../appConfig'; import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2'; import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts'; import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils'; @@ -32,7 +31,6 @@ import { PLAN_MENU_ANCHOR_IDS, renderSidebarIntro, resolveCurrentPageDescriptor, - resolvePlanOpenKeys, resolvePlanQuickFilterMenu, resolvePlayOpenKeys, resolveSavedLayoutIdFromMenuKey, @@ -148,16 +146,37 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) { return !hasAccess && topMenu !== 'docs'; } -function resolveSidebarOpenKeys(topMenu: TopMenuKey, hasAccess: boolean) { +function resolveSidebarOpenKeys( + topMenu: TopMenuKey, + hasAccess: boolean, + planMenu: PlanSectionKey, + chatMenu: ChatSectionKey, +) { if (!hasAccess) { return ['docs-group']; } - if (topMenu === 'docs' || topMenu === 'apis') { - return ['docs-group', 'api-group']; + if (topMenu === 'docs') { + return ['docs-group']; } - return topMenu === 'play' ? resolvePlayOpenKeys() : resolvePlanOpenKeys(); + if (topMenu === 'apis') { + return ['api-group']; + } + + if (topMenu === 'play') { + return resolvePlayOpenKeys(); + } + + if (topMenu === 'plans') { + return planMenu === 'server-command' ? ['server-group'] : ['plan-group']; + } + + if (chatMenu === 'errors') { + return ['app-log-group']; + } + + return chatMenu === 'manage' ? ['chat-manage-group'] : ['codex-live-group']; } export function MainLayout() { @@ -173,7 +192,9 @@ export function MainLayout() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [contentExpanded, setContentExpanded] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false); - const [sidebarOpenKeys, setSidebarOpenKeys] = useState(resolveSidebarOpenKeys(routeState.topMenu, hasAccess)); + const [sidebarOpenKeys, setSidebarOpenKeys] = useState( + resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu), + ); const [activePlanQuickFilter, setActivePlanQuickFilter] = useState< 'working' | 'release-pending-main' | 'automation-failed' | null >(routeState.planMenu === 'release' ? 'release-pending-main' : null); @@ -181,6 +202,10 @@ export function MainLayout() { const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData; const { chatUnreadCount } = useUnreadCounts(); + useEffect(() => { + void syncAppConfigFromServer(); + }, []); + useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 768px)'); const updateViewport = () => { @@ -196,14 +221,17 @@ export function MainLayout() { }, []); useEffect(() => { - if (isMobileViewport) { - setSidebarCollapsed(true); + if (!isMobileViewport) { + setSidebarCollapsed(false); + return; } - }, [isMobileViewport]); + + setSidebarCollapsed(routeState.topMenu !== 'docs'); + }, [isMobileViewport, routeState.topMenu]); useEffect(() => { - setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess)); - }, [hasAccess, routeState.topMenu]); + setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu)); + }, [hasAccess, routeState.chatMenu, routeState.planMenu, routeState.topMenu]); useEffect(() => { if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) { @@ -342,6 +370,7 @@ export function MainLayout() { const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]); const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]); const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]); + const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs'; const initialSelectedPlanId = Number(searchParams.get('planId')); const initialSelectedWorkId = searchParams.get('workId'); @@ -371,7 +400,6 @@ export function MainLayout() { > - {contentExpanded ? null : ( - {contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : ( + {contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : ( { navigate(buildDocsPath(key)); - if (isMobileViewport) { + if (isMobileViewport && !showInlineMobileDocsSidebar) { setSidebarCollapsed(true); } }} @@ -457,7 +486,7 @@ export function MainLayout() { /> )} - {isMobileViewport && !sidebarCollapsed ? null : ( + {isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : ( setContentExpanded((previous) => !previous)}> diff --git a/src/app/main/mainChatPanel/ChatConversationView.tsx b/src/app/main/mainChatPanel/ChatConversationView.tsx index d9e5dad..c7e9964 100755 --- a/src/app/main/mainChatPanel/ChatConversationView.tsx +++ b/src/app/main/mainChatPanel/ChatConversationView.tsx @@ -1,10 +1,13 @@ import { + CodeOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, + DownloadOutlined, DownOutlined, ExclamationCircleOutlined, - LinkOutlined, + FullscreenExitOutlined, + FullscreenOutlined, MessageOutlined, PaperClipOutlined, PlusOutlined, @@ -14,11 +17,25 @@ import { ThunderboltOutlined, UpOutlined, } from '@ant-design/icons'; -import { Alert, Button, Input, Select, Spin } from 'antd'; +import { Alert, Button, Input, Select, Spin, message } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type RefObject, type TouchEvent } from 'react'; -import { ChatPreviewBody } from './ChatPreviewBody'; +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type ReactNode, + type RefObject, + type TouchEvent, +} from 'react'; +import { InlineImage } from '../../../components/common/InlineImage'; +import { CodexDiffBlock } from '../../../components/previewer'; +import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody'; +import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers'; import { normalizeChatResourceUrl } from './chatResourceUrl'; +import { copyPreviewContent, copyText } from './chatUtils'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; const KST_TIME_ZONE = 'Asia/Seoul'; @@ -44,6 +61,7 @@ type ChatTypeOption = { type PreviewOption = { id: string; label: string; + url: string; kind: string; }; @@ -53,7 +71,7 @@ type QueuedRequestOption = { text: string; }; -type InlinePreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; +type InlinePreviewKind = ChatPreviewKind; type InlinePreviewTarget = { url: string; @@ -66,9 +84,17 @@ type PreviewFetchError = Error & { }; const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g; +const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; +const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; +const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; + +type MessageRenderPayload = { + visibleText: string; + diffBlocks: string[]; +}; function normalizeInlinePreviewUrl(value: string) { return normalizeChatResourceUrl(value); @@ -89,6 +115,10 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind { return 'markdown'; } + if (/\.(diff|patch)$/i.test(pathname)) { + return 'diff'; + } + if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { return 'code'; } @@ -104,6 +134,22 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind { return 'file'; } +function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') { + if (typeof document === 'undefined') { + return; + } + + const blob = new Blob([content], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); +} + function buildInlinePreviewLabel(url: string) { try { const parsed = new URL(url); @@ -143,7 +189,7 @@ async function createPreviewFetchError(response: Response): Promise(); const targets: InlinePreviewTarget[] = []; @@ -170,6 +216,81 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { return targets; } +function renderMessageInlineParts(line: string): ReactNode[] { + const renderedParts: ReactNode[] = []; + let cursor = 0; + + for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) { + const [fullMatch, label, rawHref] = match; + const start = match.index ?? 0; + + if (start > cursor) { + renderedParts.push(line.slice(cursor, start)); + } + + const href = normalizeInlinePreviewUrl(rawHref.trim()); + renderedParts.push( + + {label.trim() || href} + , + ); + cursor = start + fullMatch.length; + } + + if (cursor < line.length) { + renderedParts.push(line.slice(cursor)); + } + + return renderedParts.length > 0 ? renderedParts : [line]; +} + +function renderMessageBody(text: string) { + const lines = text.split('\n'); + + return lines.map((line, index) => { + const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN); + + if (imageMatch) { + const [, alt, rawSrc] = imageMatch; + const src = normalizeInlinePreviewUrl(rawSrc.trim()); + + return ( +
+ +
+ ); + } + + if (!line.length) { + return {isExpanded ? ( @@ -384,6 +560,100 @@ function InlineMessagePreview({ ); } +function DiffMessagePreview({ + diffText, + fileCount, + isExpanded, + isFullscreen, + onToggle, + onToggleFullscreen, +}: { + diffText: string; + fileCount: number; + isExpanded: boolean; + isFullscreen: boolean; + onToggle: () => void; + onToggleFullscreen: () => void; +}) { + const handleCopyDiff = () => { + void copyText(diffText) + .then(() => { + message.success('diff를 복사했습니다.'); + }) + .catch((error: unknown) => { + message.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.'); + }); + }; + + return ( +
+
+
+ +
+ Codex Diff + {`diff preview · 파일 ${fileCount}개`} +
+
+
+
+
+ {isExpanded || isFullscreen ? ( +
+
+ +
+
+ ) : null} +
+ ); +} + type ChatConversationViewProps = { viewportRef: RefObject; composerRef: RefObject; @@ -418,9 +688,10 @@ type ChatConversationViewProps = { onSelectChatType: (value: string) => void; onSend: () => void; onSendImmediate: () => void; + onClearDraft: () => void; onScrollToBottom: () => void; onToggleResourceStrip: () => void; - onOpenPreview: (previewId: string) => void; + onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void; onCopyMessage: (message: ChatMessage) => void; onRetryMessage: (message: ChatMessage) => void; onCancelMessage: (message: ChatMessage) => void; @@ -462,6 +733,7 @@ export function ChatConversationView({ onSelectChatType, onSend, onSendImmediate, + onClearDraft, onScrollToBottom, onToggleResourceStrip, onOpenPreview, @@ -473,34 +745,88 @@ export function ChatConversationView({ }: ChatConversationViewProps) { const [expandedMessageIds, setExpandedMessageIds] = useState([]); const [expandedPreviewKey, setExpandedPreviewKey] = useState(null); + const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState(null); const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState([]); const [collapsibleMessageIds, setCollapsibleMessageIds] = useState([]); + const [showBusyOverlay, setShowBusyOverlay] = useState(false); const fileInputRef = useRef(null); const activitySectionRefs = useRef(new Map()); const messageBodyRefs = useRef(new Map()); const autoCollapsedActivityRequestIdsRef = useRef(new Set()); const orderedMessages = useMemo(() => { - const lastActivityIndexByKey = new Map(); - - visibleMessages.forEach((message, index) => { - if (!isActivityLogMessage(message)) { - return; - } - - const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`; - lastActivityIndexByKey.set(activityKey, index); - }); - - return visibleMessages.filter((message, index) => { + const latestActivityByRequestId = new Map(); + const orphanActivityMessages: ChatMessage[] = []; + const baseMessages = visibleMessages.filter((message) => { if (!isActivityLogMessage(message)) { return true; } - const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`; - return lastActivityIndexByKey.get(activityKey) === index; + const activityKey = message.clientRequestId?.trim(); + + if (!activityKey) { + orphanActivityMessages.push(message); + return false; + } + + latestActivityByRequestId.set(activityKey, message); + return false; }); + const insertedActivityRequestIds = new Set(); + const ordered: ChatMessage[] = []; + + baseMessages.forEach((message) => { + ordered.push(message); + + if (message.author !== 'user') { + return; + } + + const requestId = message.clientRequestId?.trim(); + + if (!requestId) { + return; + } + + const activityMessage = latestActivityByRequestId.get(requestId); + + if (!activityMessage) { + return; + } + + ordered.push(activityMessage); + insertedActivityRequestIds.add(requestId); + }); + + latestActivityByRequestId.forEach((message, requestId) => { + if (!insertedActivityRequestIds.has(requestId)) { + orphanActivityMessages.push(message); + } + }); + + return [...ordered, ...orphanActivityMessages]; }, [visibleMessages]); + const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + const { body, documentElement } = document; + const previousBodyOverflow = body.style.overflow; + const previousHtmlOverflow = documentElement.style.overflow; + + if (fullscreenPreviewKey) { + body.style.overflow = 'hidden'; + documentElement.style.overflow = 'hidden'; + } + + return () => { + body.style.overflow = previousBodyOverflow; + documentElement.style.overflow = previousHtmlOverflow; + }; + }, [fullscreenPreviewKey]); const setActivitySectionRef = (requestId: string, element: HTMLElement | null) => { if (element) { @@ -651,6 +977,28 @@ export function ChatConversationView({ }; }, [orderedMessages, expandedMessageIds]); + useEffect(() => { + if (isConversationLoading) { + setShowBusyOverlay(false); + return; + } + + if (!isComposerAttachmentUploading) { + setShowBusyOverlay(false); + return; + } + + const timeoutId = window.setTimeout(() => { + setShowBusyOverlay(true); + }, 350); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isComposerAttachmentUploading, isConversationLoading]); + + const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.'; + const handleComposerFileChange = (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ''; @@ -662,6 +1010,32 @@ export function ChatConversationView({ void onPickComposerFiles(files); }; + const handleComposerPaste = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + + if (!clipboardData) { + return; + } + + const itemFiles = Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []); + + if (files.length === 0) { + return; + } + + event.preventDefault(); + + const uniqueFiles = Array.from( + new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(), + ); + + void onPickComposerFiles(uniqueFiles); + }; + const renderActivityCard = (message: ChatMessage) => { const requestId = message.clientRequestId?.trim() || String(message.id); const isExpanded = !collapsedActivityRequestIds.includes(requestId); @@ -748,7 +1122,19 @@ export function ChatConversationView({
) : null} -
+ {showBusyOverlay ? ( +
+ + {busyOverlayLabel} + 처리가 끝나면 화면이 바로 갱신됩니다. +
+ ) : null} + +
- ) : null} - {message.author === 'user' && message.deliveryStatus === 'failed' ? ( - <> +
+ {shouldRenderStandalonePreview ? null : ( +
+
+
+ {message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'} + {formatChatTimestamp(message.timestamp)} + {message.author === 'user' && requestStatusLabel ? ( + + {requestStatusLabel} + + ) : null} + {message.author === 'user' && message.deliveryStatus === 'retrying' ? ( + + + {message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'} + + ) : null} + {message.author === 'user' && message.deliveryStatus === 'failed' ? ( + + + 전송 실패 + + ) : null} + {message.author === 'user' && + (message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? ( - - ) : null} - {message.author === 'user' && - requestState?.canDelete && - requestState.status !== 'accepted' ? ( + ) : null} + {message.author === 'user' && message.deliveryStatus === 'failed' ? ( + <> + + + ) : null} + {message.author === 'user' && + requestState?.canDelete && + requestState.status !== 'accepted' ? ( +
+ {message.author !== 'system' ? (
- {message.author !== 'system' ? ( +
{ + setMessageBodyRef(message.id, element); + }} + className={messageBodyClassName} + > + {visibleText ? renderMessageBody(visibleText) : null} +
+ {message.author === 'user' && requestDetailText ? ( +
+ {requestDetailText} +
+ ) : null} + {canCollapseMessage ? ( ) : null} -
-
{ - setMessageBodyRef(message.id, element); - }} - className={messageBodyClassName} - > - {message.text} -
- {message.author === 'user' && requestDetailText ? ( -
- {requestDetailText} -
- ) : null} - {canCollapseMessage ? ( - - ) : null} - - {inlinePreviewTargets.length > 0 ? ( + + )} + {hasPreviewCards ? (
- {inlinePreviewTargets.map((target) => ( - { - const nextKey = `${message.id}-${target.url}`; - setExpandedPreviewKey((current) => (current === nextKey ? null : nextKey)); - }} - /> - ))} + {diffBlocks.map((diffText, index) => { + const previewKey = `${message.id}-diff-${index}`; + + return ( + { + setExpandedPreviewKey((current) => { + if (fullscreenPreviewKey === previewKey) { + setFullscreenPreviewKey(null); + return null; + } + + return current === previewKey ? null : previewKey; + }); + }} + onToggleFullscreen={() => { + setFullscreenPreviewKey((current) => { + const nextKey = current === previewKey ? null : previewKey; + + if (nextKey) { + setExpandedPreviewKey(previewKey); + } + + return nextKey; + }); + }} + /> + ); + })} + {inlinePreviewTargets.map((target) => { + const previewKey = `${message.id}-${target.url}`; + const matchedPreview = previewItemsByUrl.get(target.url); + + return ( + { + if (matchedPreview) { + onOpenPreview(matchedPreview.id, { fullscreen: true }); + return; + } + }} + onToggle={() => { + setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey)); + }} + /> + ); + })}
) : null}
@@ -1094,6 +1537,7 @@ export function ChatConversationView({ onChange={(event) => { onDraftChange(event.target.value); }} + onPaste={handleComposerPaste} onKeyDown={(event) => { if (event.key !== 'Enter' || event.nativeEvent.isComposing) { return; @@ -1113,6 +1557,16 @@ export function ChatConversationView({ onSend(); }} /> + ; + case 'video': + return ; + case 'markdown': + return ; + case 'code': + case 'diff': + return ; + case 'document': + return ; + case 'pdf': + return ; + default: + return ; + } +} + +export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) { + switch (kind) { + case 'image': + return 'image preview'; + case 'video': + return 'video preview'; + case 'markdown': + return 'markdown preview'; + case 'code': + return 'code preview'; + case 'diff': + return 'diff preview'; + case 'document': + return 'document preview'; + case 'pdf': + return 'pdf preview'; + default: + return 'resource preview'; + } +} + function resolvePreviewErrorMessage(previewError: string) { const normalized = previewError.trim(); @@ -247,10 +299,10 @@ export function ChatPreviewBody({ ); } - if (target.kind === 'code' || target.kind === 'document') { + if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') { const resolvedLanguage = resolveCodeLanguage(target, previewText); - if (resolvedLanguage === 'diff') { + if (target.kind === 'diff' || resolvedLanguage === 'diff') { return (
- - +
); diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index d2a4673..8d5d7b0 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity'; -import { getRegisteredAccessToken } from '../tokenAccess'; +import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess'; import { reportClientError } from '../errorLogApi'; import type { ChatActivityEvent, @@ -17,28 +17,66 @@ import type { ChatViewContext, } from './types'; -const CONNECT_TIMEOUT_MS = 8000; +const CONNECT_TIMEOUT_MS = 20000; const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id'; const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:'; const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:'; -const CHAT_SESSION_MESSAGES_STORAGE_PREFIX = 'main-chat-panel:messages:'; const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:'; const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const KST_TIME_ZONE = 'Asia/Seoul'; const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500; +const chatSessionLastTypeMemory = new Map(); +const chatLastEventIdMemory = new Map(); +const chatOfflineNotificationMemory = new Map(); +let chatClientSessionIdMemory = ''; let localMessageSequence = 0; let cachedChatConversationList: ChatConversationSummary[] | null = null; let cachedChatConversationListAt = 0; let chatConversationListRequestPromise: Promise | null = null; +export function invalidateChatConversationListCache() { + cachedChatConversationList = null; + cachedChatConversationListAt = 0; + chatConversationListRequestPromise = null; +} + +function toConversationSortTime(value: string | null | undefined) { + if (typeof value !== 'string' || !value.trim()) { + return 0; + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function sortChatConversationSummaries(items: ChatConversationSummary[]) { + return [...items].sort((left, right) => { + const leftTime = Math.max( + toConversationSortTime(left.lastMessageAt), + toConversationSortTime(left.updatedAt), + toConversationSortTime(left.createdAt), + ); + const rightTime = Math.max( + toConversationSortTime(right.lastMessageAt), + toConversationSortTime(right.updatedAt), + toConversationSortTime(right.createdAt), + ); + + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + return left.sessionId.localeCompare(right.sessionId, 'ko-KR'); + }); +} + export const CHAT_CONNECTION = { - reconnectDelayMs: 3000, + reconnectDelayMs: 1500, connectTimeoutMs: CONNECT_TIMEOUT_MS, sessionIdKey: CHAT_SESSION_ID_KEY, lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX, notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX, - sessionMessagesStoragePrefix: CHAT_SESSION_MESSAGES_STORAGE_PREFIX, sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX, introMessage: CHAT_INTRO_MESSAGE, } as const; @@ -49,21 +87,6 @@ function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | nul return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`; } -function buildLastEventIdStorageKey(sessionId: string) { - const normalizedSessionId = sessionId.trim() || 'default'; - return `${CHAT_CONNECTION.lastEventIdStoragePrefix}${normalizedSessionId}`; -} - -function buildSessionMessagesStorageKey(sessionId: string) { - const normalizedSessionId = sessionId.trim() || 'default'; - return `${CHAT_CONNECTION.sessionMessagesStoragePrefix}${normalizedSessionId}`; -} - -function buildSessionLastChatTypeStorageKey(sessionId: string) { - const normalizedSessionId = sessionId.trim() || 'default'; - return `${CHAT_CONNECTION.sessionLastTypeStoragePrefix}${normalizedSessionId}`; -} - function createBrowserSessionId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -77,29 +100,10 @@ export function clearStoredChatClientConversationState() { return; } - const keysToRemove: string[] = []; - - for (let index = 0; index < window.localStorage.length; index += 1) { - const key = window.localStorage.key(index); - - if (!key) { - continue; - } - - if ( - key === CHAT_CONNECTION.sessionIdKey || - key.startsWith(CHAT_CONNECTION.lastEventIdStoragePrefix) || - key.startsWith(CHAT_CONNECTION.notifyOfflineStoragePrefix) || - key.startsWith(CHAT_CONNECTION.sessionMessagesStoragePrefix) || - key.startsWith(CHAT_CONNECTION.sessionLastTypeStoragePrefix) - ) { - keysToRemove.push(key); - } - } - - keysToRemove.forEach((key) => { - window.localStorage.removeItem(key); - }); + chatClientSessionIdMemory = ''; + chatSessionLastTypeMemory.clear(); + chatLastEventIdMemory.clear(); + chatOfflineNotificationMemory.clear(); } function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest { @@ -123,15 +127,12 @@ export function getChatClientSessionId() { return ''; } - const existing = window.localStorage.getItem(CHAT_CONNECTION.sessionIdKey)?.trim(); - - if (existing) { - return existing; + if (chatClientSessionIdMemory) { + return chatClientSessionIdMemory; } - const nextSessionId = createBrowserSessionId(); - window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, nextSessionId); - return nextSessionId; + chatClientSessionIdMemory = createBrowserSessionId(); + return chatClientSessionIdMemory; } export function setChatClientSessionId(sessionId: string) { @@ -145,7 +146,7 @@ export function setChatClientSessionId(sessionId: string) { return; } - window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, normalizedSessionId); + chatClientSessionIdMemory = normalizedSessionId; } export function getLastReceivedChatEventId(sessionId: string) { @@ -159,9 +160,7 @@ export function getLastReceivedChatEventId(sessionId: string) { return 0; } - const raw = window.localStorage.getItem(buildLastEventIdStorageKey(normalizedSessionId)); - const parsed = raw ? Number(raw) : NaN; - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + return chatLastEventIdMemory.get(normalizedSessionId) ?? 0; } export function persistLastReceivedChatEventId(sessionId: string, eventId: number) { @@ -181,7 +180,7 @@ export function persistLastReceivedChatEventId(sessionId: string, eventId: numbe return; } - window.localStorage.setItem(buildLastEventIdStorageKey(normalizedSessionId), String(eventId)); + chatLastEventIdMemory.set(normalizedSessionId, eventId); } export function resetLastReceivedChatEventId(sessionId: string) { @@ -195,7 +194,7 @@ export function resetLastReceivedChatEventId(sessionId: string) { return; } - window.localStorage.removeItem(buildLastEventIdStorageKey(normalizedSessionId)); + chatLastEventIdMemory.delete(normalizedSessionId); } export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { @@ -203,13 +202,13 @@ export function getStoredChatOfflineNotificationSetting(sessionId: string, clien return null; } - const raw = window.localStorage.getItem(buildNotifyOfflineStorageKey(sessionId, clientId)); + const key = buildNotifyOfflineStorageKey(sessionId, clientId); - if (raw === null) { + if (!chatOfflineNotificationMemory.has(key)) { return null; } - return raw === 'true'; + return chatOfflineNotificationMemory.get(key) ?? null; } export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) { @@ -217,7 +216,7 @@ export function setStoredChatOfflineNotificationSetting(sessionId: string, enabl return; } - window.localStorage.setItem(buildNotifyOfflineStorageKey(sessionId, clientId), enabled ? 'true' : 'false'); + chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled); } export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { @@ -225,7 +224,7 @@ export function clearStoredChatOfflineNotificationSetting(sessionId: string, cli return; } - window.localStorage.removeItem(buildNotifyOfflineStorageKey(sessionId, clientId)); + chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId)); } function resolveSyncedChatOfflineNotificationSetting( @@ -247,106 +246,31 @@ function resolveSyncedChatOfflineNotificationSetting( return serverValue; } -export function loadStoredChatMessages(sessionId: string) { - if (typeof window === 'undefined') { - return [] as ChatMessage[]; - } - - const normalizedSessionId = sessionId.trim(); - - if (!normalizedSessionId) { - return [] as ChatMessage[]; - } - - try { - const raw = window.localStorage.getItem(buildSessionMessagesStorageKey(normalizedSessionId)); - - if (!raw) { - return [] as ChatMessage[]; - } - - const parsed = JSON.parse(raw) as ChatMessage[]; - - if (!Array.isArray(parsed)) { - return [] as ChatMessage[]; - } - - return parsed - .filter((message) => - Boolean(message) && - (message.author === 'codex' || message.author === 'system' || message.author === 'user') && - typeof message.text === 'string' && - typeof message.timestamp === 'string' && - typeof message.id === 'number', - ) - .filter((message) => message.author !== 'system' || isActivityLogMessage(message)); - } catch { - return [] as ChatMessage[]; - } -} - export function getStoredChatSessionLastTypeId(sessionId: string) { - if (typeof window === 'undefined') { - return null; - } - const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return null; } - const raw = window.localStorage.getItem(buildSessionLastChatTypeStorageKey(normalizedSessionId))?.trim() ?? ''; + const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? ''; return raw || null; } export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) { - if (typeof window === 'undefined') { - return; - } - const normalizedSessionId = sessionId.trim(); const normalizedChatTypeId = chatTypeId.trim(); - if (!normalizedSessionId || !normalizedChatTypeId) { - return; - } - - window.localStorage.setItem( - buildSessionLastChatTypeStorageKey(normalizedSessionId), - normalizedChatTypeId, - ); -} - -export function persistStoredChatMessages(sessionId: string, messages: ChatMessage[]) { - if (typeof window === 'undefined') { - return; - } - - const normalizedSessionId = sessionId.trim(); - if (!normalizedSessionId) { return; } - window.localStorage.setItem( - buildSessionMessagesStorageKey(normalizedSessionId), - JSON.stringify(messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message))), - ); -} - -export function clearStoredChatMessages(sessionId: string) { - if (typeof window === 'undefined') { + if (!normalizedChatTypeId) { + chatSessionLastTypeMemory.delete(normalizedSessionId); return; } - const normalizedSessionId = sessionId.trim(); - - if (!normalizedSessionId) { - return; - } - - window.localStorage.removeItem(buildSessionMessagesStorageKey(normalizedSessionId)); + chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId); } export function formatTime(date: Date) { @@ -369,6 +293,20 @@ function createLocalMessageId() { return Date.now() * 1_000 + localMessageSequence; } +function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') { + const baseId = hashRequestId(requestId) * 10; + + if (variant === 'user') { + return -(baseId + 3); + } + + if (variant === 'activity') { + return -(baseId + 2); + } + + return -(baseId + 1); +} + function hashRequestId(value: string) { let hash = 0; @@ -606,6 +544,7 @@ export function buildOfflineReply(context: ChatViewContext, input: string) { export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) { const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL; const resolvedClientId = clientId || getOrCreateClientId(); + const accessToken = getRegisteredAccessToken(); if (typeof window === 'undefined') { return ''; @@ -626,6 +565,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number if (resolvedClientId) { normalizedUrl.searchParams.set('clientId', resolvedClientId); } + if (accessToken) { + normalizedUrl.searchParams.set('accessToken', accessToken); + } if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { normalizedUrl.searchParams.set('lastEventId', String(lastEventId)); } @@ -641,6 +583,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number if (resolvedClientId) { url.searchParams.set('clientId', resolvedClientId); } + if (accessToken) { + url.searchParams.set('accessToken', accessToken); + } if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { url.searchParams.set('lastEventId', String(lastEventId)); } @@ -736,6 +681,106 @@ export async function copyText(text: string) { } } +export type PreviewCopyResult = 'text' | 'image' | 'url'; + +async function copyImagePreview(url: string): Promise { + const response = await fetch(url, { + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`preview 이미지를 가져오지 못했습니다. (${response.status})`); + } + + const imageBlob = await response.blob(); + + if (!imageBlob.type.startsWith('image/')) { + throw new Error('이미지 preview만 이미지 자체를 복사할 수 있습니다.'); + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') { + await navigator.clipboard.write([ + new ClipboardItem({ + [imageBlob.type]: imageBlob, + }), + ]); + return 'image'; + } + + await copyText(url); + return 'url'; +} + +function canCopyPreviewBody(kind: string | null | undefined) { + return !['image', 'video', 'pdf', 'file'].includes(String(kind ?? '').trim().toLowerCase()); +} + +export async function copyPreviewContent({ + kind, + url, + fallbackText, +}: { + kind: string | null | undefined; + url: string; + fallbackText?: string | null; +}): Promise { + const normalizedKind = String(kind ?? '').trim().toLowerCase(); + + if (normalizedKind === 'image') { + return copyImagePreview(url); + } + + const previewBody = await resolvePreviewBodyForCopy({ + kind, + url, + fallbackText, + }); + await copyText(previewBody); + return 'text'; +} + +export async function resolvePreviewBodyForCopy({ + kind, + url, + fallbackText, +}: { + kind: string | null | undefined; + url: string; + fallbackText?: string | null; +}) { + const normalizedFallbackText = String(fallbackText ?? ''); + + if (!canCopyPreviewBody(kind)) { + throw new Error('이 미리보기는 본문 텍스트를 복사할 수 없습니다.'); + } + + try { + const response = await fetch(url, { + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`preview 본문을 가져오지 못했습니다. (${response.status})`); + } + + const bodyText = await response.text(); + + if (bodyText.trim()) { + return bodyText; + } + } catch (error) { + if (!normalizedFallbackText.trim()) { + throw error; + } + } + + if (normalizedFallbackText.trim()) { + return normalizedFallbackText; + } + + throw new Error('복사할 preview 본문이 없습니다.'); +} + function resolveChatApiBaseUrl() { if (import.meta.env.VITE_WORK_SERVER_URL) { return import.meta.env.VITE_WORK_SERVER_URL; @@ -751,6 +796,11 @@ async function requestChatApi(path: string, init?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 8000); + if (!hasRegisteredAccessTokenAccess()) { + window.clearTimeout(timeoutId); + throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.'); + } + if (accessToken && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', accessToken); } @@ -775,17 +825,43 @@ async function requestChatApi(path: string, init?: RequestInit): Promise { throw new Error('채팅 서버 응답이 지연됩니다.'); } - throw error; + throw new Error('채팅 서버 연결에 실패했습니다.'); } window.clearTimeout(timeoutId); if (!response.ok) { const text = await response.text(); - throw new Error(text || '채팅 API 요청에 실패했습니다.'); + + if (text.trim()) { + try { + const payload = JSON.parse(text) as { message?: string }; + const normalizedMessage = String(payload.message ?? '').trim(); + + if (normalizedMessage) { + throw new Error(normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage); + } + } catch (error) { + if (error instanceof Error && error.message) { + throw error; + } + } + } + + throw new Error('채팅 API 요청에 실패했습니다.'); } - return response.json() as Promise; + const text = await response.text(); + + if (!text.trim()) { + throw new Error('채팅 서버 응답이 비어 있습니다.'); + } + + try { + return JSON.parse(text) as T; + } catch { + throw new Error('채팅 서버 응답을 해석하지 못했습니다.'); + } } async function readFileAsBase64(file: File) { @@ -827,10 +903,12 @@ export async function fetchChatConversations() { const clientId = getOrCreateClientId(); chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations') .then((response) => { - const items = response.items.map((item) => ({ - ...item, - notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId), - })); + const items = sortChatConversationSummaries( + response.items.map((item) => ({ + ...item, + notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId), + })), + ); cachedChatConversationList = items; cachedChatConversationListAt = Date.now(); @@ -864,19 +942,23 @@ export async function fetchChatConversationDetail( const response = await requestChatApi( `/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`, ); + const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item)); const visibleRequestIds = new Set( response.messages .map((message) => message.clientRequestId?.trim() ?? '') .filter(Boolean), ); + const hydratedMessages = hydrateActivityLogMessages( + response.messages, + response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), + ).filter( + (message) => message.author !== 'system' || isActivityLogMessage(message), + ); + const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs); + return { ...response, - messages: hydrateActivityLogMessages( - response.messages, - response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), - ).filter( - (message) => message.author !== 'system' || isActivityLogMessage(message), - ), + messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages), item: { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting( @@ -885,7 +967,7 @@ export async function fetchChatConversationDetail( clientId, ), }, - requests: response.requests.map((item) => normalizeChatConversationRequest(item)), + requests: normalizedRequests, }; } @@ -945,6 +1027,7 @@ export async function uploadChatComposerFile(sessionId: string, file: File) { export async function createChatConversationRoom(args: { sessionId: string; title?: string; + chatTypeId?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; @@ -956,6 +1039,7 @@ export async function createChatConversationRoom(args: { body: JSON.stringify({ sessionId: args.sessionId, title: args.title ?? '새 대화', + chatTypeId: args.chatTypeId ?? null, contextLabel: args.contextLabel ?? null, contextDescription: args.contextDescription ?? null, notifyOffline, @@ -963,6 +1047,8 @@ export async function createChatConversationRoom(args: { }), }); + invalidateChatConversationListCache(); + return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), @@ -980,6 +1066,8 @@ export async function renameChatConversationRoom(sessionId: string, title: strin }, ); + invalidateChatConversationListCache(); + return response.item; } @@ -987,6 +1075,9 @@ export async function updateChatConversationRoom( sessionId: string, payload: { title?: string; + chatTypeId?: string | null; + contextLabel?: string | null; + contextDescription?: string | null; notifyOffline?: boolean; }, ) { @@ -999,6 +1090,8 @@ export async function updateChatConversationRoom( }, ); + invalidateChatConversationListCache(); + return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), @@ -1025,6 +1118,8 @@ export async function deleteChatConversationRoom(sessionId: string) { }, ); + invalidateChatConversationListCache(); + return response; } @@ -1116,8 +1211,8 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) { } return Boolean( - left.author === 'user' && - right.author === 'user' && + (left.author === 'user' || left.author === 'codex') && + left.author === right.author && left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId, @@ -1133,9 +1228,78 @@ function buildComparableChatMessageKey(message: ChatMessage) { return `user-request:${message.clientRequestId}`; } + if (message.author === 'codex' && message.clientRequestId) { + return `codex-request:${message.clientRequestId}`; + } + return `id:${message.id}`; } +function getComparableChatMessageTime(message: ChatMessage) { + const parsed = Date.parse(String(message.timestamp ?? '').trim()); + return Number.isFinite(parsed) ? parsed : 0; +} + +function buildRecoveredMessagesFromConversationDetail( + requests: ChatConversationRequest[], + activityLogs: ChatConversationActivityLog[], +) { + const nextMessages: ChatMessage[] = []; + const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item])); + + requests.forEach((request) => { + const requestId = request.requestId.trim(); + + if (!requestId || request.status === 'removed') { + return; + } + + const userText = String(request.userText ?? '').trim(); + const responseText = String(request.responseText ?? '').trim(); + const activityLog = activityLogMap.get(requestId); + + if (userText) { + nextMessages.push({ + id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'), + author: 'user', + text: userText, + timestamp: request.createdAt || request.updatedAt || '', + clientRequestId: requestId, + }); + } + + if (responseText) { + nextMessages.push({ + id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'), + author: 'codex', + text: responseText, + timestamp: request.answeredAt || request.updatedAt || request.createdAt || '', + clientRequestId: requestId, + }); + } + + if (activityLog && activityLog.lines.length > 0) { + nextMessages.push({ + id: createRecoveredMessageId(requestId, 'activity'), + author: 'system', + text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${activityLog.lines.join('\n\n')}`, + timestamp: request.createdAt || request.updatedAt || activityLog.updatedAt || '', + clientRequestId: requestId, + }); + } + }); + + return nextMessages.sort((left, right) => { + const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right); + + if (timeDiff !== 0) { + return timeDiff; + } + + return left.id - right.id; + }); +} + export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) { if (previous.length === 0) { return incoming; diff --git a/src/app/main/mainChatPanel/downloadUtils.ts b/src/app/main/mainChatPanel/downloadUtils.ts new file mode 100644 index 0000000..5e4764b --- /dev/null +++ b/src/app/main/mainChatPanel/downloadUtils.ts @@ -0,0 +1,47 @@ +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function isMobileLikeViewport() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(max-width: 1180px)').matches === true || + window.matchMedia?.('(pointer: coarse)').matches === true + ); +} + +export function shouldOpenDownloadInNewWindow() { + return isStandaloneDisplayMode() && isMobileLikeViewport(); +} + +export function triggerResourceDownload(url: string, fileName?: string) { + if (typeof document === 'undefined') { + throw new Error('download-unavailable'); + } + + const link = document.createElement('a'); + link.href = url; + + if (shouldOpenDownloadInNewWindow()) { + link.target = '_blank'; + link.rel = 'noreferrer'; + } else if (typeof fileName === 'string' && fileName.trim()) { + link.download = fileName.trim(); + } else { + link.download = ''; + } + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/src/app/main/mainChatPanel/index.ts b/src/app/main/mainChatPanel/index.ts index b5a9c34..f882909 100644 --- a/src/app/main/mainChatPanel/index.ts +++ b/src/app/main/mainChatPanel/index.ts @@ -4,7 +4,9 @@ export { ErrorLogViewer } from './ErrorLogViewer'; export { buildOfflineReply, clearStoredChatClientConversationState, + copyPreviewContent, copyText, + resolvePreviewBodyForCopy, createActivityLogPlaceholder, createChatConversationRoom, createChatMessage, @@ -20,15 +22,14 @@ export { getStoredChatSessionLastTypeId, isPreparingChatReplyText, getChatClientSessionId, - loadStoredChatMessages, markChatConversationResponsesRead, mergeRecoveredChatMessages, - persistStoredChatMessages, renameChatConversationRoom, removeChatRuntimeJob, resetLastReceivedChatEventId, setStoredChatSessionLastTypeId, setChatClientSessionId, + sortChatConversationSummaries, uploadChatComposerFile, upsertChatMessage, updateChatConversationRoom, diff --git a/src/app/main/mainChatPanel/previewMarkers.ts b/src/app/main/mainChatPanel/previewMarkers.ts new file mode 100644 index 0000000..470ff20 --- /dev/null +++ b/src/app/main/mainChatPanel/previewMarkers.ts @@ -0,0 +1,14 @@ +const HIDDEN_PREVIEW_TAG_PATTERN = /\[\[preview:([^\]\n]+)\]\]/g; + +export function extractHiddenPreviewUrls(text: string) { + return Array.from(String(text ?? '').matchAll(HIDDEN_PREVIEW_TAG_PATTERN)) + .map((match) => match[1]?.trim()) + .filter((value): value is string => Boolean(value)); +} + +export function stripHiddenPreviewTags(text: string) { + return String(text ?? '') + .replace(HIDDEN_PREVIEW_TAG_PATTERN, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/app/main/mainChatPanel/types.ts b/src/app/main/mainChatPanel/types.ts index ead78e4..2c925b7 100755 --- a/src/app/main/mainChatPanel/types.ts +++ b/src/app/main/mainChatPanel/types.ts @@ -37,6 +37,7 @@ export type ChatConversationSummary = { sessionId: string; clientId: string | null; title: string; + chatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; diff --git a/src/app/main/mainChatPanel/useChatConnection.ts b/src/app/main/mainChatPanel/useChatConnection.ts index 9fda2c4..13926e4 100755 --- a/src/app/main/mainChatPanel/useChatConnection.ts +++ b/src/app/main/mainChatPanel/useChatConnection.ts @@ -8,6 +8,7 @@ import { persistLastReceivedChatEventId, resolveChatWebSocketUrl, } from './chatUtils'; +import { hasRegisteredAccessTokenAccess } from '../tokenAccess'; import type { ChatActivityEvent, ChatJobEvent, @@ -19,7 +20,6 @@ import type { const DISCONNECT_UI_DELAY_MS = 1500; const PRESENCE_PING_INTERVAL_MS = 20_000; -const BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS = 15_000; type ConnectionState = 'connecting' | 'connected' | 'disconnected'; @@ -225,14 +225,13 @@ function sendPresencePing() { ); } -function refreshSharedSocket() { +function ensureSharedSocket() { const socket = sharedChatConnection.socketRef.current; - if (socket && socket.readyState === WebSocket.CONNECTING) { + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return; } - disconnectSharedSocket(); connectSharedSocket(); } @@ -280,24 +279,6 @@ function stopPresenceMonitoring() { } } -function shouldRefreshSocketAfterResume() { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return false; - } - - const socket = sharedChatConnection.socketRef.current; - - if (!socket || socket.readyState !== WebSocket.OPEN) { - return true; - } - - if (sharedChatConnection.lastBackgroundAt === null) { - return false; - } - - return Date.now() - sharedChatConnection.lastBackgroundAt >= BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS; -} - function handleVisibilityChange() { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { sharedChatConnection.lastBackgroundAt = Date.now(); @@ -306,36 +287,24 @@ function handleVisibilityChange() { return; } - if (shouldRefreshSocketAfterResume()) { - refreshSharedSocket(); - return; - } - + ensureSharedSocket(); sendPresencePing(); sendContextUpdate(sharedChatConnection.currentContext); } function handlePageShow() { - if (shouldRefreshSocketAfterResume()) { - refreshSharedSocket(); - return; - } - + ensureSharedSocket(); sendPresencePing(); sendContextUpdate(sharedChatConnection.currentContext); } function handleWindowFocus() { - if (shouldRefreshSocketAfterResume()) { - refreshSharedSocket(); - return; - } - + ensureSharedSocket(); sendPresencePing(); } function handleWindowOnline() { - refreshSharedSocket(); + ensureSharedSocket(); } function startPresenceMonitoring() { @@ -444,6 +413,16 @@ function connectSharedSocket() { return; } + if (!hasRegisteredAccessTokenAccess()) { + clearReconnectTimer(); + clearConnectTimeout(); + clearDisconnectUiTimer(); + stopPresenceMonitoring(); + setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.'); + setSharedConnectionState('disconnected'); + return; + } + const currentSocket = sharedChatConnection.socketRef.current; if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) { @@ -496,6 +475,13 @@ function connectSharedSocket() { return; } + if (closeEvent?.code === 1008) { + clearReconnectTimer(); + setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.'); + setSharedConnectionState('disconnected'); + return; + } + if (closeEvent?.code === 1000 && !message) { setSharedConnectionError(''); return; diff --git a/src/app/main/pwaRegisterStub.ts b/src/app/main/pwaRegisterStub.ts new file mode 100644 index 0000000..d037907 --- /dev/null +++ b/src/app/main/pwaRegisterStub.ts @@ -0,0 +1,5 @@ +export function registerSW() { + return async function updateServiceWorker() { + return; + }; +} diff --git a/src/app/main/routes.tsx b/src/app/main/routes.tsx index 6fa5a8d..93bbdba 100755 --- a/src/app/main/routes.tsx +++ b/src/app/main/routes.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react'; import type { PlanFilterStatus } from '../../features/planBoard'; export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play'; -export type HeaderTopMenuKey = 'docs' | 'plans' | 'play'; +export type HeaderTopMenuKey = 'docs' | 'plans'; export type ApiSectionKey = 'components' | 'widgets'; export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command'; export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage'; @@ -370,7 +370,7 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st return buildDocsPath(currentDocsFolder); } - return menu === 'plans' ? buildPlansPath('all') : buildPlayPath('layout'); + return buildPlansPath('all'); } export function createPageWindowId(topMenu: TopMenuKey, section: string) { diff --git a/src/app/main/types.ts b/src/app/main/types.ts index f3ba623..70385cc 100755 --- a/src/app/main/types.ts +++ b/src/app/main/types.ts @@ -34,6 +34,7 @@ export type MainSidebarProps = { hasAccess: boolean; sidebarCollapsed: boolean; isMobileViewport: boolean; + mobileInline?: boolean; openKeys: string[]; apiMenuItems: MenuProps['items']; docsMenuItems: MenuProps['items']; diff --git a/src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx b/src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx index 5373d53..5ff80cd 100755 --- a/src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx +++ b/src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx @@ -2,11 +2,12 @@ import { AudioOutlined, CodeOutlined, CopyOutlined, - EyeOutlined, + DownloadOutlined, FileImageOutlined, FileMarkdownOutlined, FilePdfOutlined, FileTextOutlined, + FullscreenOutlined, LinkOutlined, PlayCircleOutlined, } from '@ant-design/icons'; @@ -128,6 +129,81 @@ async function copyAttachmentValue(attachment: EvidenceAttachmentItem) { document.body.removeChild(textarea); } +function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) { + if (typeof attachment.title === 'string' && attachment.title.trim()) { + return attachment.title.trim(); + } + + if (attachment.linkUrl) { + try { + const resolvedUrl = new URL( + attachment.linkUrl, + typeof window !== 'undefined' ? window.location.origin : 'https://test.sm-home.cloud/', + ); + const fileName = resolvedUrl.pathname.split('/').pop()?.trim(); + + if (fileName) { + return fileName; + } + } catch { + return `${attachment.key}.txt`; + } + } + + return `${attachment.key}.txt`; +} + +function resolveAttachmentMimeType(attachment: EvidenceAttachmentItem) { + switch (attachment.kind) { + case 'markdown': + return 'text/markdown;charset=utf-8'; + case 'json': + return 'application/json;charset=utf-8'; + case 'code': + case 'text': + case 'empty': + return 'text/plain;charset=utf-8'; + default: + return 'application/octet-stream'; + } +} + +function downloadAttachmentValue(attachment: EvidenceAttachmentItem) { + if (typeof document === 'undefined') { + throw new Error('download-unavailable'); + } + + const fileName = resolveAttachmentDownloadFileName(attachment); + const link = document.createElement('a'); + + if (attachment.linkUrl) { + link.href = attachment.linkUrl; + + if (attachment.kind === 'preview') { + link.target = '_blank'; + link.rel = 'noreferrer'; + } else { + link.download = fileName; + } + } else { + const blob = new Blob([attachment.value], { + type: resolveAttachmentMimeType(attachment), + }); + const objectUrl = URL.createObjectURL(blob); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + return; + } + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + function resolvePreviewerType(kind: EvidenceAttachmentKind) { switch (kind) { case 'markdown': @@ -260,6 +336,14 @@ export function EvidenceAttachmentStrip({ } } + async function handleDownload(attachment: EvidenceAttachmentItem) { + try { + downloadAttachmentValue(attachment); + } catch { + message.error('다운로드에 실패했습니다.'); + } + } + if (attachments.length === 0) { return (
@@ -308,29 +392,27 @@ export function EvidenceAttachmentStrip({ void handleCopy(attachment); }} /> - {attachment.linkUrl ? ( + {attachment.linkUrl || attachment.value ? ( + aria-label="다운로드" + icon={} + onClick={() => { + void handleDownload(attachment); + }} + /> ) : null} {onPreview ? ( + /> ) : null} } diff --git a/src/components/evidenceAttachmentStrip/samples/Sample.tsx b/src/components/evidenceAttachmentStrip/samples/Sample.tsx index 17d3f1c..03c4ae4 100755 --- a/src/components/evidenceAttachmentStrip/samples/Sample.tsx +++ b/src/components/evidenceAttachmentStrip/samples/Sample.tsx @@ -1,4 +1,11 @@ -import { App, Card, Flex, Modal, Space, Switch, Typography } from 'antd'; +import { + CloseOutlined, + CopyOutlined, + DownloadOutlined, + FullscreenExitOutlined, + FullscreenOutlined, +} from '@ant-design/icons'; +import { App, Button, Card, Flex, Modal, Space, Switch, Typography } from 'antd'; import { useState } from 'react'; import type { SampleMeta } from '../../../widgets/core'; import { @@ -96,6 +103,7 @@ export function Sample() { const { message } = App.useApp(); const [compact, setCompact] = useState(false); const [selectedAttachment, setSelectedAttachment] = useState(null); + const [isPreviewExpanded, setIsPreviewExpanded] = useState(false); return ( @@ -130,13 +138,51 @@ export function Sample() { open={Boolean(selectedAttachment)} title={selectedAttachment?.title ?? 'Attachment Preview'} footer={null} - width={1080} + width={isPreviewExpanded ? 'calc(100vw - 32px)' : 1080} onCancel={() => { setSelectedAttachment(null); + setIsPreviewExpanded(false); }} > {selectedAttachment ? ( - + + +
{diffSections.map((section) => { - const isExpanded = expandedDiffPaths.includes(section.path); + const isExpanded = expandedDiffPath === section.path; const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path; return ( @@ -230,11 +243,7 @@ export function CodexDiffBlock({ type="button" className="codex-diff-previewer__diff-toggle" onClick={() => { - setExpandedDiffPaths((currentPaths) => - currentPaths.includes(section.path) - ? currentPaths.filter((path) => path !== section.path) - : [...currentPaths, section.path], - ); + setExpandedDiffPath((currentPath) => (currentPath === section.path ? null : section.path)); }} > diff --git a/src/components/previewer/CodexDiffPreviewer.css b/src/components/previewer/CodexDiffPreviewer.css index f15f48a..796425e 100755 --- a/src/components/previewer/CodexDiffPreviewer.css +++ b/src/components/previewer/CodexDiffPreviewer.css @@ -58,9 +58,10 @@ .codex-diff-previewer__diff-section--expanded { position: fixed; - inset: 16px; + inset: 0; z-index: 1250; - border-radius: 20px; + border-radius: 0; + border-inline: 0; background: #0f172a; box-shadow: 0 28px 72px rgba(15, 23, 42, 0.38), @@ -95,7 +96,7 @@ } .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body { - height: calc(100vh - 68px); + height: calc(100vh - 60px); overflow: auto; } diff --git a/src/components/previewer/CodexDiffPreviewer.tsx b/src/components/previewer/CodexDiffPreviewer.tsx index 1959789..6bdc273 100755 --- a/src/components/previewer/CodexDiffPreviewer.tsx +++ b/src/components/previewer/CodexDiffPreviewer.tsx @@ -1,5 +1,6 @@ import { CopyOutlined, + DownloadOutlined, DownOutlined, FileImageOutlined, FileTextOutlined, @@ -92,7 +93,7 @@ export function CodexDiffPreviewer({ }: CodexDiffPreviewerProps) { const [messageApi, contextHolder] = message.useMessage(); const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff'); - const [expandedSourcePaths, setExpandedSourcePaths] = useState(() => files.slice(0, 1).map((file) => file.path)); + const [expandedSourcePath, setExpandedSourcePath] = useState(() => files[0]?.path ?? null); const [expandedPreviewPath, setExpandedPreviewPath] = useState(null); const statusCount = useMemo(() => buildStatusCount(files), [files]); const canShowSource = files.length > 0; @@ -129,6 +130,24 @@ export function CodexDiffPreviewer({ } } + function handleDownload(path: string, content: string) { + if (typeof document === 'undefined') { + messageApi.error('다운로드를 사용할 수 없습니다.'); + return; + } + + const fileName = path.split('/').pop() || 'preview.txt'; + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + } + function handleFullscreen(path: string) { setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path)); } @@ -141,15 +160,12 @@ export function CodexDiffPreviewer({ return; } - setExpandedSourcePaths((currentPaths) => { - const availablePaths = new Set(files.map((file) => file.path)); - const nextPaths = currentPaths.filter((path) => availablePaths.has(path)); - - if (nextPaths.length > 0) { - return nextPaths; + setExpandedSourcePath((currentPath) => { + if (currentPath && files.some((file) => file.path === currentPath)) { + return currentPath; } - return files[0] ? [files[0].path] : []; + return files[0]?.path ?? null; }); }, [canShowSource, diffText, files]); @@ -231,15 +247,15 @@ export function CodexDiffPreviewer({
{files.map((file) => { - const isExpanded = expandedSourcePaths.includes(file.path); + const isExpanded = expandedSourcePath === file.path; const isPreviewExpanded = expandedPreviewPath === file.path; const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path; @@ -266,11 +282,7 @@ export function CodexDiffPreviewer({ type="button" className="codex-diff-previewer__diff-toggle" onClick={() => { - setExpandedSourcePaths((currentPaths) => - currentPaths.includes(file.path) - ? currentPaths.filter((path) => path !== file.path) - : [...currentPaths, file.path], - ); + setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path)); }} > @@ -296,6 +308,16 @@ export function CodexDiffPreviewer({ void handleCopy(file.content); }} /> + + + />
diff --git a/src/styles.css b/src/styles.css index 8d911e9..81a5798 100755 --- a/src/styles.css +++ b/src/styles.css @@ -17,6 +17,13 @@ body, overflow-x: hidden; } +html, +body { + background: + radial-gradient(circle at top, rgba(22, 93, 255, 0.14), transparent 34%), + linear-gradient(180deg, #f8fbff 0%, #eef4ff 45%, #ffffff 100%); +} + .markdown-preview__image { display: block; width: 100%; @@ -35,6 +42,7 @@ body { min-width: 320px; font-family: 'SUIT Variable', 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; + color: #182230; } img, diff --git a/src/sw.js b/src/sw.js index b5d24c2..6aeb7a0 100755 --- a/src/sw.js +++ b/src/sw.js @@ -76,6 +76,10 @@ self.addEventListener('notificationclick', (event) => { const notificationCategory = typeof notificationData.category === 'string' ? notificationData.category.trim() : ''; const notificationType = typeof notificationData.type === 'string' ? notificationData.type.trim() : ''; const notificationThreadId = typeof notificationData.threadId === 'string' ? notificationData.threadId.trim() : ''; + const isChatNotification = + notificationCategory === 'chat' || + notificationType.startsWith('chat') || + notificationThreadId.startsWith('chat:'); event.notification.close(); let targetUrl; @@ -98,6 +102,17 @@ self.addEventListener('notificationclick', (event) => { targetUrl.searchParams.set('topMenu', notificationData.category === 'chat' ? 'chat' : 'plans'); } + if (isChatNotification) { + targetUrl.pathname = '/chat/live'; + targetUrl.searchParams.set('topMenu', 'chat'); + targetUrl.searchParams.delete('chatView'); + targetUrl.searchParams.delete('runtimeRequestId'); + + if (notificationSessionId) { + targetUrl.searchParams.set('sessionId', notificationSessionId); + } + } + if (notificationData.planId && !targetUrl.searchParams.has('planId')) { targetUrl.searchParams.set('planId', String(notificationData.planId)); } @@ -109,11 +124,6 @@ self.addEventListener('notificationclick', (event) => { event.waitUntil( Promise.all([ self.registration.getNotifications().then((notifications) => { - const isChatNotification = - notificationCategory === 'chat' || - notificationType.startsWith('chat') || - notificationThreadId.startsWith('chat:'); - if (!notificationSessionId || !isChatNotification) { return; } diff --git a/vite.config.ts b/vite.config.ts index 81c81dc..d50fe96 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import { cpSync, existsSync, mkdirSync, readdirSync } from 'fs'; -import { join } from 'path'; +import { join, resolve } from 'path'; +import { fileURLToPath } from 'url'; import { defineConfig, type ResolvedConfig, type ViteDevServer } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; @@ -19,6 +20,8 @@ const VITE_PUBLIC_HMR_CLIENT_PORT = Number(processEnv.VITE_PUBLIC_HMR_CLIENT_POR const VITE_EMPTY_OUT_DIR = processEnv.VITE_EMPTY_OUT_DIR !== 'false'; const VITE_FILTER_PUBLIC_DIR = processEnv.VITE_FILTER_PUBLIC_DIR === 'true'; const VITE_DISABLE_MODULE_PRELOAD = processEnv.VITE_DISABLE_MODULE_PRELOAD === 'true'; +const VITE_DISABLE_PWA = processEnv.VITE_DISABLE_PWA === 'true'; +const ROOT_DIR = fileURLToPath(new URL('.', import.meta.url)); function shouldIgnoreDevUpdatePath(watchedPath: string) { return ( @@ -121,6 +124,13 @@ function copyPublicAssetsExceptCodexChat() { } export default defineConfig({ + resolve: { + alias: VITE_DISABLE_PWA + ? { + 'virtual:pwa-register': resolve(ROOT_DIR, 'src/app/main/pwaRegisterStub.ts'), + } + : undefined, + }, build: { copyPublicDir: !VITE_FILTER_PUBLIC_DIR, emptyOutDir: VITE_EMPTY_OUT_DIR, @@ -163,44 +173,48 @@ export default defineConfig({ react(), createDevAppUpdatePlugin(), copyPublicAssetsExceptCodexChat(), - VitePWA({ - strategies: 'injectManifest', - srcDir: 'src', - filename: 'sw.js', - registerType: 'prompt', - includeAssets: ['favicon.svg', 'apple-touch-icon.svg'], - injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'], - globIgnores: ['**/.codex_chat/**'], - maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, - }, - manifest: { - name: 'AI Code App', - short_name: 'AI Code App', - description: 'Ant Design 기반 UI 샘플과 문서를 확인하는 AI Code App', - theme_color: '#165dff', - background_color: '#eff5ff', - display: 'standalone', - lang: 'ko', - scope: '/', - start_url: '/', - icons: [ - { - src: '/pwa-192x192.svg', - sizes: '192x192', - type: 'image/svg+xml', - }, - { - src: '/pwa-512x512.svg', - sizes: '512x512', - type: 'image/svg+xml', - purpose: 'any maskable', - }, - ], - }, - devOptions: { - enabled: true, - }, - }), - ], + !VITE_DISABLE_PWA && + VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.js', + registerType: 'prompt', + includeAssets: ['favicon.svg', 'apple-touch-icon.svg'], + workbox: { + maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, + }, + injectManifest: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'], + globIgnores: ['**/.codex_chat/**'], + maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, + }, + manifest: { + name: 'AI Code App', + short_name: 'AI Code App', + description: 'Ant Design 기반 UI 샘플과 문서를 확인하는 AI Code App', + theme_color: '#165dff', + background_color: '#eff5ff', + display: 'standalone', + lang: 'ko', + scope: '/', + start_url: '/', + icons: [ + { + src: '/pwa-192x192.svg', + sizes: '192x192', + type: 'image/svg+xml', + }, + { + src: '/pwa-512x512.svg', + sizes: '512x512', + type: 'image/svg+xml', + purpose: 'any maskable', + }, + ], + }, + devOptions: { + enabled: true, + }, + }), + ].filter(Boolean), });