feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

22
.dockerignore Normal file
View File

@@ -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

View File

@@ -10,6 +10,9 @@
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다 * 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다 * 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`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` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다 * 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
* `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 * `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
@@ -44,6 +47,7 @@
* `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 * `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다 * 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
* 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준의 로컬 작업으로 연결한다 * 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준의 로컬 작업으로 연결한다
* 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다 * 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다

29
Dockerfile.preview Normal file
View File

@@ -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"]

View File

@@ -17,6 +17,34 @@ npm install
npm run dev 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 ## PhotoPrism
루트 `docker-compose.yml`에는 PhotoPrism와 MariaDB 서비스가 포함되어 있습니다. 루트 `docker-compose.yml`에는 PhotoPrism와 MariaDB 서비스가 포함되어 있습니다.

View File

@@ -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

View File

@@ -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

View File

@@ -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/<chat-session-id>/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 검수, 차트처럼 화면이 분리된 기능은 개별 문서를 유지

View File

@@ -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
```
## 변경 파일
-

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'; 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) { export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => { 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) => { app.put('/api/app-config', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};

View File

@@ -6,16 +6,14 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.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 { import {
createChatConversation, createChatConversation,
deleteUnansweredChatConversationRequest, deleteUnansweredChatConversationRequest,
deleteChatConversation, deleteChatConversation,
ensureChatConversationTables, ensureChatConversationTables,
getChatConversation, getChatConversation,
listChatConversationActivityLogs, listChatConversationDetailPage,
listChatConversationMessages,
listChatConversationRequests,
listChatConversations, listChatConversations,
markChatConversationResponsesRead, markChatConversationResponsesRead,
updateChatConversationContext, updateChatConversationContext,
@@ -136,7 +134,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
} }
function resolveChatAttachmentRepoPath() { 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<string, unknown> }) { function getClientIdHeader(request: { headers: Record<string, unknown> }) {
@@ -314,6 +312,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const payload = z.object({ const payload = z.object({
sessionId: z.string().trim().min(1).max(120), sessionId: z.string().trim().min(1).max(120),
title: z.string().trim().max(200).optional(), title: z.string().trim().max(200).optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
contextLabel: z.string().trim().max(200).optional(), contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(2000).optional(), contextDescription: z.string().trim().max(2000).optional(),
notifyOffline: z.boolean().optional(), notifyOffline: z.boolean().optional(),
@@ -324,6 +323,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
sessionId: payload.sessionId, sessionId: payload.sessionId,
clientId: clientId || null, clientId: clientId || null,
title: payload.title ?? '새 대화', title: payload.title ?? '새 대화',
chatTypeId: payload.chatTypeId ?? null,
contextLabel: payload.contextLabel ?? null, contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null, contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true, notifyOffline: payload.notifyOffline ?? true,
@@ -353,30 +353,20 @@ export async function registerChatRoutes(app: FastifyInstance) {
}); });
} }
const messageLimit = query.limit ?? 500; const messageLimit = query.limit ?? 6;
const messages = await listChatConversationMessages(params.sessionId, { const detailPage = await listChatConversationDetailPage(params.sessionId, {
limit: messageLimit, limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null, 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 { return {
ok: true, ok: true,
item, item,
messages, messages: detailPage.messages,
requests, requests: detailPage.requests,
activityLogs, activityLogs: detailPage.activityLogs,
oldestLoadedMessageId, oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages, hasOlderMessages: detailPage.hasOlderMessages,
}; };
}); });
@@ -447,6 +437,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}).parse(request.params ?? {}); }).parse(request.params ?? {});
const payload = z.object({ const payload = z.object({
title: z.string().trim().min(1).max(200).optional(), 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(), contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(2000).optional().nullable(), contextDescription: z.string().trim().max(2000).optional().nullable(),
notifyOffline: z.boolean().optional(), notifyOffline: z.boolean().optional(),
@@ -464,6 +455,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const item = await updateChatConversationContext(params.sessionId, { const item = await updateChatConversationContext(params.sessionId, {
title: payload.title ?? current.title, title: payload.title ?? current.title,
clientId: current.clientId, clientId: current.clientId,
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
contextLabel: payload.contextLabel ?? current.contextLabel, contextLabel: payload.contextLabel ?? current.contextLabel,
contextDescription: payload.contextDescription ?? current.contextDescription, contextDescription: payload.contextDescription ?? current.contextDescription,
notifyOffline: payload.notifyOffline ?? current.notifyOffline, 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); const deleted = await deleteChatConversation(params.sessionId);
return { return {

View File

@@ -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',
},
});
});

View File

@@ -39,6 +39,61 @@ const deleteSchema = z.object({
}); });
const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']); 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<string, unknown>) {
if (!loginFieldPattern.test(fieldName)) {
return false;
}
return Object.keys(row).some((candidateField) => secretFieldPattern.test(candidateField));
}
export function maskCrudRowSensitiveFields<T>(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<string, unknown>;
const result: Record<string, unknown> = {};
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<typeof filterSchema>[] = []) { function applyFilters(query: Knex.QueryBuilder, filters: z.infer<typeof filterSchema>[] = []) {
filters.forEach((filter) => { filters.forEach((filter) => {
@@ -138,7 +193,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true, ok: true,
table, table,
count: rows.length, count: rows.length,
rows, rows: maskCrudRowSensitiveFields(rows),
}; };
}); });
@@ -150,7 +205,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
table, table,
rows: inserted, rows: maskCrudRowSensitiveFields(inserted),
}; };
}); });
@@ -197,7 +252,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true, ok: true,
table, table,
count: rows.length, count: rows.length,
rows, rows: maskCrudRowSensitiveFields(rows),
}; };
}); });
@@ -214,7 +269,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true, ok: true,
table, table,
count: rows.length, count: rows.length,
rows, rows: maskCrudRowSensitiveFields(rows),
}; };
}); });
} }

View File

@@ -2,7 +2,7 @@ import { env } from './config/env.js';
import { db } from './db/client.js'; import { db } from './db/client.js';
import { createApp } from './app.js'; import { createApp } from './app.js';
import { ChatService } from './services/chat-service.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 { shutdownNotificationProvider } from './services/notification-service.js';
import { PlanWorker } from './workers/plan-worker.js'; import { PlanWorker } from './workers/plan-worker.js';
@@ -13,6 +13,7 @@ app.server.on('upgrade', chatService.attachUpgradeHandler());
async function start() { async function start() {
try { try {
await ensureChatConversationTables();
await clearAllChatConversationJobStates(); await clearAllChatConversationJobStates();
await app.listen({ await app.listen({
host: '0.0.0.0', host: '0.0.0.0',

View File

@@ -1,6 +1,7 @@
import { db } from '../db/client.js'; import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs'; export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
async function ensureAppConfigTable() { async function ensureAppConfigTable() {
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
@@ -49,6 +50,14 @@ export async function getAppConfig() {
return row.config_json ?? {}; return row.config_json ?? {};
} }
function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
}
return value as Record<string, unknown>;
}
export type AppConfigSnapshot = { export type AppConfigSnapshot = {
chat?: { chat?: {
maxContextMessages?: number; maxContextMessages?: number;
@@ -107,25 +116,49 @@ export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
export async function upsertAppConfig(config: Record<string, unknown>) { export async function upsertAppConfig(config: Record<string, unknown>) {
await ensureAppConfigTable(); await ensureAppConfigTable();
const nextConfig = normalizeConfigRecord(config);
const existing = await db(APP_CONFIG_TABLE).first(); const existing = await db(APP_CONFIG_TABLE).first();
if (!existing) { if (!existing) {
const rows = await db(APP_CONFIG_TABLE) const rows = await db(APP_CONFIG_TABLE)
.insert({ .insert({
config_json: config, config_json: nextConfig,
updated_at: db.fn.now(), updated_at: db.fn.now(),
}) })
.returning('*'); .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) const rows = await db(APP_CONFIG_TABLE)
.update({ .update({
config_json: config, config_json: mergedConfig,
updated_at: db.fn.now(), updated_at: db.fn.now(),
}) })
.returning('*'); .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[];
} }

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
buildChatConversationRequestPatchFromMessage, buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage,
mergeChatConversationRequestStatus, mergeChatConversationRequestStatus,
shouldClearConversationJobState, shouldClearConversationJobState,
selectChatConversationResponseCandidate, 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', () => { test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
assert.deepEqual( assert.deepEqual(
buildChatConversationRequestPatchFromMessage({ buildChatConversationRequestPatchFromMessage({

View File

@@ -14,6 +14,7 @@ const conversationPayloadSchema = z.object({
sessionId: z.string().trim().min(1).max(120), sessionId: z.string().trim().min(1).max(120),
clientId: z.string().trim().max(120).nullable().optional(), clientId: z.string().trim().max(120).nullable().optional(),
title: z.string().trim().max(200).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(), contextLabel: z.string().trim().max(200).nullable().optional(),
contextDescription: z.string().trim().max(2000).nullable().optional(), contextDescription: z.string().trim().max(2000).nullable().optional(),
notifyOffline: z.boolean().optional(), notifyOffline: z.boolean().optional(),
@@ -32,6 +33,7 @@ export type ChatConversationItem = {
sessionId: string; sessionId: string;
clientId: string | null; clientId: string | null;
title: string; title: string;
chatTypeId: string | null;
contextLabel: string | null; contextLabel: string | null;
contextDescription: string | null; contextDescription: string | null;
notifyOffline: boolean; notifyOffline: boolean;
@@ -88,6 +90,14 @@ export type ChatConversationActivityLogItem = {
updatedAt: string | null; updatedAt: string | null;
}; };
export type ChatConversationDetailPage = {
messages: StoredChatMessage[];
requests: ChatConversationRequestItem[];
activityLogs: ChatConversationActivityLogItem[];
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
};
type ChatConversationRequestStatusPatch = { type ChatConversationRequestStatusPatch = {
requestId: string; requestId: string;
status?: ChatConversationRequestStatus; status?: ChatConversationRequestStatus;
@@ -113,6 +123,25 @@ type ChatConversationClientPreference = {
lastReadResponseMessageId: number | null; 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) { function createPreview(text: string) {
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
@@ -143,6 +172,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
sessionId: String(row.session_id ?? ''), sessionId: String(row.session_id ?? ''),
clientId: row.client_id == null ? null : String(row.client_id), clientId: row.client_id == null ? null : String(row.client_id),
title: String(row.title ?? '새 대화'), 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), contextLabel: row.context_label == null ? null : String(row.context_label),
contextDescription: row.context_description == null ? null : String(row.context_description), contextDescription: row.context_description == null ? null : String(row.context_description),
notifyOffline: Boolean(row.notify_offline), notifyOffline: Boolean(row.notify_offline),
@@ -151,11 +181,11 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], 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), currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
currentQueueSize: Number(row.current_queue_size ?? 0), 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 ?? ''), lastMessagePreview: String(row.last_message_preview ?? ''),
createdAt: String(row.created_at ?? ''), createdAt: normalizeDateTimeValue(row.created_at) ?? '',
updatedAt: String(row.updated_at ?? ''), updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), lastMessageAt: normalizeDateTimeValue(row.last_message_at),
}; };
} }
@@ -169,7 +199,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
}; };
} }
function isVisibleConversationMessage(message: StoredChatMessage) { export function isVisibleConversationMessage(message: StoredChatMessage) {
if (message.author !== 'system') { if (message.author !== 'system') {
return true; return true;
} }
@@ -177,6 +207,12 @@ function isVisibleConversationMessage(message: StoredChatMessage) {
return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); 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<string, unknown>): ChatConversationClientPreference { function mapClientPreferenceRow(row: Record<string, unknown>): ChatConversationClientPreference {
return { return {
sessionId: String(row.session_id ?? ''), sessionId: String(row.session_id ?? ''),
@@ -203,10 +239,10 @@ function mapRequestRow(row: Record<string, unknown>): ChatConversationRequestIte
responseText: String(row.response_text ?? ''), responseText: String(row.response_text ?? ''),
hasResponse, hasResponse,
canDelete, canDelete,
createdAt: String(row.created_at ?? ''), createdAt: normalizeDateTimeValue(row.created_at) ?? '',
updatedAt: String(row.updated_at ?? ''), updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
answeredAt: row.answered_at == null ? null : String(row.answered_at), answeredAt: normalizeDateTimeValue(row.answered_at),
terminalAt: row.terminal_at == null ? null : String(row.terminal_at), terminalAt: normalizeDateTimeValue(row.terminal_at),
}; };
} }
@@ -560,7 +596,7 @@ async function getLatestPreviewableMessageMap(sessionIds: string[]) {
messageMap.set(sessionId, { messageMap.set(sessionId, {
text: String(row.text ?? ''), 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, { requestMap.set(sessionId, {
text: userText, 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('session_id', 120).primary();
table.string('client_id', 120).nullable().index(); table.string('client_id', 120).nullable().index();
table.string('title', 200).notNullable().defaultTo('새 대화'); table.string('title', 200).notNullable().defaultTo('새 대화');
table.string('chat_type_id', 120).nullable();
table.string('context_label', 200).nullable(); table.string('context_label', 200).nullable();
table.text('context_description').nullable(); table.text('context_description').nullable();
table.boolean('notify_offline').notNullable().defaultTo(false); table.boolean('notify_offline').notNullable().defaultTo(false);
@@ -690,6 +727,7 @@ export async function ensureChatConversationTables() {
const requiredConversationColumns: Array<[string, (table: any) => void]> = [ const requiredConversationColumns: Array<[string, (table: any) => void]> = [
['client_id', (table) => table.string('client_id', 120).nullable().index()], ['client_id', (table) => table.string('client_id', 120).nullable().index()],
['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], ['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_label', (table) => table.string('context_label', 200).nullable()],
['context_description', (table) => table.text('context_description').nullable()], ['context_description', (table) => table.text('context_description').nullable()],
['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], ['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) { export async function getChatConversation(sessionId: string, clientId?: string | null) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); 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({ shouldClearConversationJobState({
currentRequestId, currentRequestId,
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], 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), runtimeActive: isRuntimeRequestActive(currentRequestId),
request: currentRequestId request: currentRequestId
? await db(CHAT_CONVERSATION_REQUEST_TABLE) ? await db(CHAT_CONVERSATION_REQUEST_TABLE)
@@ -894,8 +931,8 @@ export async function getChatConversation(sessionId: string, clientId?: string |
status: String(requestRow.status ?? '') as ChatConversationRequestStatus, status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
responseText: String(requestRow.response_text ?? ''), responseText: String(requestRow.response_text ?? ''),
terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at), terminalAt: normalizeDateTimeValue(requestRow.terminal_at),
updatedAt: requestRow.updated_at == null ? null : String(requestRow.updated_at), updatedAt: normalizeDateTimeValue(requestRow.updated_at),
} }
: null, : null,
) )
@@ -966,7 +1003,6 @@ export async function getChatConversation(sessionId: string, clientId?: string |
} }
export async function createChatConversation(payload: z.input<typeof conversationPayloadSchema>) { export async function createChatConversation(payload: z.input<typeof conversationPayloadSchema>) {
await ensureChatConversationTables();
const parsed = conversationPayloadSchema.parse(payload); const parsed = conversationPayloadSchema.parse(payload);
const normalizedClientId = normalizeClientId(parsed.clientId); const normalizedClientId = normalizeClientId(parsed.clientId);
const notifyOffline = parsed.notifyOffline ?? true; const notifyOffline = parsed.notifyOffline ?? true;
@@ -975,6 +1011,7 @@ export async function createChatConversation(payload: z.input<typeof conversatio
session_id: parsed.sessionId, session_id: parsed.sessionId,
client_id: normalizedClientId, client_id: normalizedClientId,
title: parsed.title?.trim() || '새 대화', title: parsed.title?.trim() || '새 대화',
chat_type_id: parsed.chatTypeId?.trim() || null,
context_label: parsed.contextLabel?.trim() || null, context_label: parsed.contextLabel?.trim() || null,
context_description: parsed.contextDescription?.trim() || null, context_description: parsed.contextDescription?.trim() || null,
notify_offline: notifyOffline, notify_offline: notifyOffline,
@@ -1013,12 +1050,12 @@ export async function updateChatConversationContext(
payload: { payload: {
title?: string | null; title?: string | null;
clientId?: string | null; clientId?: string | null;
chatTypeId?: string | null;
contextLabel?: string | null; contextLabel?: string | null;
contextDescription?: string | null; contextDescription?: string | null;
notifyOffline?: boolean | null; notifyOffline?: boolean | null;
}, },
) { ) {
await ensureChatConversationTables();
const normalizedClientId = normalizeClientId(payload.clientId); const normalizedClientId = normalizeClientId(payload.clientId);
const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();
@@ -1031,6 +1068,7 @@ export async function updateChatConversationContext(
.update({ .update({
title: payload.title?.trim() || current.title || '새 대화', title: payload.title?.trim() || current.title || '새 대화',
client_id: normalizedClientId || current.client_id || null, client_id: normalizedClientId || current.client_id || null,
chat_type_id: payload.chatTypeId?.trim() || null,
context_label: payload.contextLabel?.trim() || null, context_label: payload.contextLabel?.trim() || null,
context_description: payload.contextDescription?.trim() || null, context_description: payload.contextDescription?.trim() || null,
notify_offline: notify_offline:
@@ -1052,32 +1090,44 @@ export async function listChatConversations(
limit = 50, limit = 50,
unreadStateClientId?: string | null, unreadStateClientId?: string | null,
) { ) {
await ensureChatConversationTables();
const normalizedClientId = normalizeClientId(clientId); const normalizedClientId = normalizeClientId(clientId);
const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId); const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId);
const query = db(CHAT_CONVERSATION_TABLE) const normalizedLimit = Math.max(1, Math.min(200, Math.round(limit)));
.select('*') let conversationListScopeClientId = normalizedClientId;
.orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST') const buildConversationListQuery = (targetClientId?: string | null) => {
.orderByRaw('last_message_at DESC NULLS LAST') const query = db(CHAT_CONVERSATION_TABLE)
.orderByRaw('updated_at DESC NULLS LAST') .select('*')
.orderByRaw('created_at DESC NULLS LAST') .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST')
.limit(Math.max(1, Math.min(200, Math.round(limit)))); .orderByRaw('last_message_at DESC NULLS LAST')
.orderByRaw('updated_at DESC NULLS LAST')
.orderByRaw('created_at DESC NULLS LAST')
.limit(normalizedLimit);
if (normalizedClientId) { if (targetClientId) {
query.where((builder) => { query.where((builder) => {
builder builder
.where({ client_id: normalizedClientId }) .where({ client_id: targetClientId })
.orWhereExists( .orWhereExists(
db(CHAT_CONVERSATION_CLIENT_TABLE) db(CHAT_CONVERSATION_CLIENT_TABLE)
.select(db.raw('1')) .select(db.raw('1'))
.whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`)
.andWhere({ client_id: normalizedClientId }), .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 sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean);
const currentRequestIds = Array.from( const currentRequestIds = Array.from(
new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)), 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, status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
responseText: String(requestRow.response_text ?? ''), 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({ shouldClearConversationJobState({
currentRequestId: String(row.current_request_id ?? ''), currentRequestId: String(row.current_request_id ?? ''),
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], 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 ?? '')), runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')),
request: request:
requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null, 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(), current_status_updated_at: db.fn.now(),
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; beforeMessageId?: number | null;
} = {}, } = {},
) { ) {
await ensureChatConversationTables();
const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200))); const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200)));
const normalizedBeforeMessageId = const normalizedBeforeMessageId =
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
@@ -1244,20 +1293,226 @@ export async function listChatConversationMessages(
if (normalizedBeforeMessageId !== null) { if (normalizedBeforeMessageId !== null) {
builder.where('message_id', '<', normalizedBeforeMessageId); builder.where('message_id', '<', normalizedBeforeMessageId);
} }
builder.andWhere((visibilityBuilder) => {
applyVisibleConversationMessageCondition(visibilityBuilder);
});
}) })
.orderBy('message_id', 'desc') .orderBy('message_id', 'desc')
.orderBy('id', 'desc') .orderBy('id', 'desc')
.limit(normalizedLimit); .limit(normalizedLimit);
const latestRows = await query; const latestRows = await query;
return latestRows return latestRows.reverse().map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row));
.reverse() }
.map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row))
.filter((message: ReturnType<typeof mapMessageRow>) => isVisibleConversationMessage(message)); async function listChatConversationActivityLogsByRequestIds(
sessionId: string,
requestIds: string[],
): Promise<ChatConversationActivityLogItem[]> {
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<string, ChatConversationActivityLogItem>();
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<ChatConversationDetailPage> {
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<typeof mapMessageRow>[0]) => mapMessageRow(row));
const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds);
const oldestLoadedMessageId =
requests.reduce<number | null>((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) { export async function listChatConversationRequests(sessionId: string, limit = 200) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); 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) { export async function getChatConversationRequest(sessionId: string, requestId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim(); const normalizedRequestId = requestId.trim();
@@ -1313,7 +1566,6 @@ export async function appendChatConversationMessage(
conversationPayload: z.input<typeof conversationPayloadSchema>, conversationPayload: z.input<typeof conversationPayloadSchema>,
messagePayload: z.input<typeof conversationMessagePayloadSchema>, messagePayload: z.input<typeof conversationMessagePayloadSchema>,
) { ) {
await ensureChatConversationTables();
const conversation = conversationPayloadSchema.parse(conversationPayload); const conversation = conversationPayloadSchema.parse(conversationPayload);
const message = conversationMessagePayloadSchema.parse(messagePayload); const message = conversationMessagePayloadSchema.parse(messagePayload);
@@ -1353,6 +1605,7 @@ export async function appendChatConversationMessage(
.update({ .update({
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
title: nextTitle, title: nextTitle,
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null, context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null, context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
notify_offline: notify_offline:
@@ -1390,7 +1643,6 @@ export async function appendChatConversationMessage(
} }
export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) { export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim(); const normalizedRequestId = requestId.trim();
const normalizedLine = line.trim(); const normalizedLine = line.trim();
@@ -1426,7 +1678,6 @@ export async function listChatConversationActivityLogs(
sessionId: string, sessionId: string,
limitRequests = 500, limitRequests = 500,
): Promise<ChatConversationActivityLogItem[]> { ): Promise<ChatConversationActivityLogItem[]> {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) { if (!normalizedSessionId) {
@@ -1469,7 +1720,7 @@ export async function listChatConversationActivityLogs(
if (existing) { if (existing) {
existing.lines.push(String(row.text ?? '')); 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; continue;
} }
@@ -1477,7 +1728,7 @@ export async function listChatConversationActivityLogs(
sessionId: String(row.session_id ?? normalizedSessionId), sessionId: String(row.session_id ?? normalizedSessionId),
requestId, requestId,
lines: [String(row.text ?? '')], 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; clear?: boolean;
}, },
) { ) {
await ensureChatConversationTables();
const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();
if (!current) { if (!current) {
@@ -1547,8 +1796,6 @@ export async function upsertChatConversationRequest(
responseText?: string | null; responseText?: string | null;
}, },
) { ) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedRequestId = payload.requestId.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'], author: String(row.author ?? 'codex') as StoredChatMessage['author'],
text: String(row.text ?? ''), text: String(row.text ?? ''),
clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), 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) { for (let index = 0; index < requestRows.length; index += 1) {
@@ -1713,12 +1960,12 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
const candidate = selectChatConversationResponseCandidate( const candidate = selectChatConversationResponseCandidate(
{ {
requestId, requestId,
createdAt: String(requestRow.created_at ?? ''), createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '',
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
}, },
nextRequestRow nextRequestRow
? { ? {
createdAt: String(nextRequestRow.created_at ?? ''), createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '',
} }
: undefined, : undefined,
responseMessages, responseMessages,
@@ -1785,8 +2032,6 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
} }
export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) { export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim(); const normalizedRequestId = requestId.trim();
const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
@@ -1866,8 +2111,6 @@ export async function clearAllChatConversationJobStates() {
} }
export async function deleteChatConversation(sessionId: string) { export async function deleteChatConversation(sessionId: string) {
await ensureChatConversationTables();
return db.transaction(async (trx) => { return db.transaction(async (trx) => {
await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del(); await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del();
await trx(CHAT_CONVERSATION_REQUEST_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) { export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
await ensureChatConversationTables();
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE) const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({ .where({
session_id: sessionId.trim(), session_id: sessionId.trim(),
@@ -1890,8 +2132,6 @@ export async function getChatConversationClientPreference(sessionId: string, cli
} }
export async function listChatConversationOfflineNotificationClientIds(sessionId: string) { export async function listChatConversationOfflineNotificationClientIds(sessionId: string) {
await ensureChatConversationTables();
const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({ .where({
session_id: sessionId.trim(), session_id: sessionId.trim(),
@@ -1905,7 +2145,6 @@ export async function listChatConversationOfflineNotificationClientIds(sessionId
} }
export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) { export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedClientId = clientId.trim(); const normalizedClientId = clientId.trim();
await db(CHAT_CONVERSATION_CLIENT_TABLE) await db(CHAT_CONVERSATION_CLIENT_TABLE)
@@ -1927,8 +2166,6 @@ export async function upsertChatConversationClientPreference(sessionId: string,
} }
export async function markChatConversationResponsesRead(sessionId: string, clientId: string) { export async function markChatConversationResponsesRead(sessionId: string, clientId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedClientId = clientId.trim(); const normalizedClientId = clientId.trim();

View File

@@ -352,6 +352,50 @@ class ChatRuntimeService {
this.emit(); 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) { private buildTerminalLog(status: ChatRuntimeTerminalStatus) {
if (status === 'completed') { if (status === 'completed') {
return '실행이 완료되었습니다.'; return '실행이 완료되었습니다.';

View File

@@ -150,7 +150,8 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources',
'response.diff', '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'); assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
}); });

View File

@@ -22,6 +22,7 @@ import {
updateChatConversationContext, updateChatConversationContext,
} from './chat-room-service.js'; } from './chat-room-service.js';
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-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 { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js'; import { createNotificationMessage } from './notification-message-service.js';
import { import {
@@ -162,6 +163,7 @@ type ChatSessionState = {
clientId: string | null; clientId: string | null;
socket: WebSocket | null; socket: WebSocket | null;
lastSeenAt: number; lastSeenAt: number;
isDeleted: boolean;
context: ChatContext | null; context: ChatContext | null;
queue: Array<{ queue: Array<{
requestId: string; requestId: string;
@@ -188,12 +190,17 @@ type ActiveChatExecution = {
}; };
let activeRuntimeController: ChatRuntimeController | null = null; let activeRuntimeController: ChatRuntimeController | null = null;
let activeChatService: ChatService | null = null;
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>(); const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
export function getChatRuntimeController() { export function getChatRuntimeController() {
return activeRuntimeController; return activeRuntimeController;
} }
export function getActiveChatService() {
return activeChatService;
}
const SOCKET_PATH = '/ws/chat'; const SOCKET_PATH = '/ws/chat';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
const STREAM_CAPTURE_LIMIT = 256 * 1024; const STREAM_CAPTURE_LIMIT = 256 * 1024;
@@ -275,8 +282,11 @@ function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId:
try { try {
const targetUrl = new URL(pageUrl); const targetUrl = new URL(pageUrl);
targetUrl.pathname = '/chat/live';
targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat'); targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
targetUrl.searchParams.set('sessionId', sessionId); targetUrl.searchParams.set('sessionId', sessionId);
targetUrl.searchParams.delete('chatView');
targetUrl.searchParams.delete('runtimeRequestId');
return targetUrl.toString(); return targetUrl.toString();
} catch { } catch {
return fallbackUrl.toString(); return fallbackUrl.toString();
@@ -397,6 +407,15 @@ function createRequestId() {
return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; 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) { function hashRequestId(value: string) {
let hash = 0; let hash = 0;
@@ -454,13 +473,19 @@ function isSocketOpen(socket: WebSocket | null | undefined) {
return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN); 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) { if (!socket) {
return; return;
} }
try { try {
socket.close(); socket.close(code, reason);
} catch (error) { } catch (error) {
logger.warn(error, message); logger.warn(error, message);
} }
@@ -1293,15 +1318,8 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
return output; return output;
} }
const lines = ['diff 리소스 경로:']; const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
return `${output}\n\n${hiddenPreviewTags}`;
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')}`;
} }
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
@@ -1452,18 +1470,22 @@ function buildAgenticCodexPrompt(
'응답 규칙:', '응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.', '- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.', '- 한국어로 간결하게 답하세요.',
'', '',
'현재 화면 문맥:', '채팅 유형 문맥(우선 적용):',
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`, `- pageTitle: ${context?.pageTitle ?? '없음'}`,
`- topMenu: ${context?.topMenu ?? '없음'}`, `- topMenu: ${context?.topMenu ?? '없음'}`,
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`,
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'', '',
isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:', isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:',
...(isTemplateRequest ...(isTemplateRequest
@@ -1627,7 +1649,7 @@ async function runAgenticCodexReply(
onProgress?: (text: string) => void, onProgress?: (text: string) => void,
onActivity?: (line: 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); await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot(); const appConfig = await getAppConfigSnapshot();
const recentHistory = const recentHistory =
@@ -2109,60 +2131,7 @@ async function buildCodexReply(
onProgress?: (text: string) => void, onProgress?: (text: string) => void,
onActivity?: (line: string) => void, onActivity?: (line: string) => void,
) { ) {
const normalized = input.toLowerCase(); return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
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);
} }
export class ChatService { export class ChatService {
@@ -2174,6 +2143,7 @@ export class ChatService {
private readonly unsubscribeRuntimeBroadcast: () => void; private readonly unsubscribeRuntimeBroadcast: () => void;
constructor(private readonly logger: FastifyBaseLogger) { constructor(private readonly logger: FastifyBaseLogger) {
activeChatService = this;
activeRuntimeController = { activeRuntimeController = {
getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId), getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
cancelJob: (requestId) => this.cancelRuntimeJob(requestId), cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
@@ -2215,6 +2185,9 @@ export class ChatService {
close() { close() {
activeRuntimeController = null; activeRuntimeController = null;
if (activeChatService === this) {
activeChatService = null;
}
this.unsubscribeRuntimeBroadcast(); this.unsubscribeRuntimeBroadcast();
for (const execution of activeChatProcessRegistry.values()) { for (const execution of activeChatProcessRegistry.values()) {
@@ -2248,6 +2221,7 @@ export class ChatService {
clientId: clientId?.trim() || null, clientId: clientId?.trim() || null,
socket: null, socket: null,
lastSeenAt: Date.now(), lastSeenAt: Date.now(),
isDeleted: false,
context: null, context: null,
queue: [], queue: [],
activeRequestCount: 0, activeRequestCount: 0,
@@ -2282,6 +2256,10 @@ export class ChatService {
} }
private persistConversationMessage(session: ChatSessionState, message: ChatMessage) { private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
if (session.isDeleted) {
return Promise.resolve();
}
const nextPersistence = session.messagePersistenceTail const nextPersistence = session.messagePersistenceTail
.catch(() => undefined) .catch(() => undefined)
.then(() => .then(() =>
@@ -2319,6 +2297,10 @@ export class ChatService {
skipOfflineNotification?: boolean; skipOfflineNotification?: boolean;
}, },
) { ) {
if (session.isDeleted) {
return this.createSessionEnvelope(session, message);
}
const envelope = this.createSessionEnvelope(session, message); const envelope = this.createSessionEnvelope(session, message);
this.retainEnvelopeForReplay(session, envelope); this.retainEnvelopeForReplay(session, envelope);
@@ -2739,7 +2721,7 @@ export class ChatService {
} }
private replaySessionHistory(session: ChatSessionState, lastEventId: number) { 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; return;
} }
@@ -2751,6 +2733,10 @@ export class ChatService {
} }
private async initializeSession(session: ChatSessionState) { private async initializeSession(session: ChatSessionState) {
if (session.isDeleted) {
return;
}
await session.messagePersistenceTail.catch(() => undefined); await session.messagePersistenceTail.catch(() => undefined);
const messages = await listChatConversationMessages(session.sessionId, { limit: 500 }); const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });
@@ -2782,6 +2768,12 @@ export class ChatService {
private async handleConnection(socket: WebSocket, request: IncomingMessage) { private async handleConnection(socket: WebSocket, request: IncomingMessage) {
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
const url = new URL(request.url ?? '/', origin); 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 requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
const clientId = url.searchParams.get('clientId')?.trim() || null; const clientId = url.searchParams.get('clientId')?.trim() || null;
const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0); const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
@@ -2819,6 +2811,62 @@ export class ChatService {
this.replaySessionHistory(session, lastEventId); 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) { private handleMessage(socket: WebSocket, raw: RawData) {
try { try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage; const message = JSON.parse(raw.toString()) as ChatInboundMessage;

View File

@@ -1,5 +1,6 @@
import { execFile, spawn } from 'node:child_process'; import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import { readFile, rm, stat } from 'node:fs/promises'; import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { promisify } from 'node:util'; 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_DELAY_MS = 2_000;
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500; const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
const DEFERRED_RESTART_POLL_INTERVAL_MS = 150; 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<SourceChangeInfo | null> {
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<T = unknown>(
method: string,
requestPath: string,
payload?: unknown,
): Promise<T> {
const socketPath = resolveDockerSocketPath(process.env);
const body = payload == null ? null : JSON.stringify(payload);
return await new Promise<T>((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<DockerContainerInspect>('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<string>((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) { function normalizeUrl(value: string) {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
@@ -181,7 +435,27 @@ function shouldRetryWithDockerSocket(error: unknown) {
const failure = error instanceof Error ? (error as ExecFileFailure) : null; const failure = error instanceof Error ? (error as ExecFileFailure) : null;
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'); 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) { export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) {
@@ -794,6 +1068,19 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null), composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null),
}; };
} catch (error) { } catch (error) {
if (shouldRetryWithDockerSocket(error)) {
try {
const inspected = await inspectContainerViaSocket(definition.containerName);
return {
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
composeStatus: inspected.State?.Status?.trim() || null,
composeDetails: trimPreview(inspected.Name?.trim() ? `name:${inspected.Name.trim().replace(/^\//, '')}` : null),
};
} catch {
// fall through to compose inspection
}
}
return inspectComposeStatus(definition); return inspectComposeStatus(definition);
} }
} }
@@ -935,8 +1222,61 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
return inspectContainerRuntime(definition); return inspectContainerRuntime(definition);
} }
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
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<BuildInspectionResult> { async function inspectBuild(definition: ServerDefinition): Promise<BuildInspectionResult> {
if (definition.key !== 'work-server') { if (definition.key !== 'work-server') {
const appBuildInfo = await inspectAppContainerBuild(definition);
if (appBuildInfo) {
return appBuildInfo;
}
return { return {
runningVersion: null, runningVersion: null,
runningBuiltAt: null, runningBuiltAt: null,

View File

@@ -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" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<meta name="theme-color" content="#165dff" /> <meta name="theme-color" content="#165dff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" /> <link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<title>AI Code App</title> <title>AI Code App</title>

View File

@@ -40,7 +40,7 @@
"plan:codex:once": "node scripts/run-plan-codex-once.mjs", "plan:codex:once": "node scripts/run-plan-codex-once.mjs",
"server-command:runner": "node scripts/run-server-command-runner.mjs", "server-command:runner": "node scripts/run-server-command-runner.mjs",
"build:app": "tsc -b && vite build --outDir app-dist", "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:lib": "tsc -p tsconfig.lib.json",
"build": "npm run build:lib && npm run build:app", "build": "npm run build:lib && npm run build:app",
"prepublishOnly": "npm run build:lib", "prepublishOnly": "npm run build:lib",

View File

@@ -663,14 +663,10 @@ async function validateCodexExecutionRuntime(repoPath, codexBin) {
async function runCodexLiveExecution(payload, response) { async function runCodexLiveExecution(payload, response) {
const requestId = String(payload?.requestId ?? '').trim(); const requestId = String(payload?.requestId ?? '').trim();
const sessionId = String(payload?.sessionId ?? '').trim(); const sessionId = String(payload?.sessionId ?? '').trim();
const repoPath = translateWorkspacePathToHost(String(payload?.repoPath ?? '').trim() || projectRoot); const repoPath = projectRoot;
const prompt = String(payload?.prompt ?? ''); const prompt = String(payload?.prompt ?? '');
const resourceDir = translateWorkspacePathToHost( const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
String(payload?.resourceDir ?? path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource')), const uploadDir = path.join(resourceDir, 'uploads');
);
const uploadDir = translateWorkspacePathToHost(
String(payload?.uploadDir ?? path.join(resourceDir, 'uploads')),
);
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
if (!requestId || !sessionId || !prompt.trim()) { if (!requestId || !sessionId || !prompt.trim()) {

View File

@@ -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<string, string>;
}) {
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<ChatMessage[]>(() => loadStoredChatMessages(getChatClientSessionId()));
const [conversation, setConversation] = useState<ChatConversationSummary | null>(null);
const notifiedIncomingMessageKeysRef = useRef<string[]>([]);
const notifiedFailedJobKeysRef = useRef<string[]>([]);
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
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<string, unknown>;
}) => {
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;
}

View File

@@ -57,7 +57,10 @@ function buildChatNotificationLink(sessionId: string) {
return ''; 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: { async function tryShowLocalChatNotification(args: {

View File

@@ -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<ChatMessage[]>([]);
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;
}

View File

@@ -1,9 +1,9 @@
import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd'; import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react'; 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 { chatGateway } from './chatV2';
import type { ChatMessage } from './mainChatPanel/types'; import type { ChatMessage, ChatConversationRequest } from './mainChatPanel/types';
import type { ChatConversationRequest } from './mainChatPanel/types';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
@@ -11,6 +11,8 @@ type ChatSourceChangeEntry = {
id: string; id: string;
sessionId: string; sessionId: string;
conversationTitle: string; conversationTitle: string;
chatTypeId: string | null;
chatTypeLabel: string;
requestId: string; requestId: string;
requestTitle: string; requestTitle: string;
questionText: string; questionText: string;
@@ -148,6 +150,12 @@ function extractCurrentSourceFiles(text: string) {
...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? [])
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), ...(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)) .map((item) => normalizeWorkspaceFilePath(item))
.filter((path) => path && isCurrentSourcePath(path)); .filter((path) => path && isCurrentSourcePath(path));
@@ -160,22 +168,12 @@ function extractCurrentSourceFiles(text: string) {
} }
function extractChangedFiles(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), text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
) )
.flatMap((match) => [match[1], match[2], match[3]]) .flatMap((match) => [match[1], match[2], match[3]])
.filter((value): value is string => Boolean(value)); .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( return Array.from(
new Set( new Set(
matches matches
@@ -248,29 +246,20 @@ function appendUniqueText(target: string[], value: string | null | undefined) {
target.push(normalized); target.push(normalized);
} }
function resolveDeploymentStatus(updatedAt: string, latestReleaseCompletedAt: string | null): DeploymentFilterValue { function resolveDeploymentStatus(
if (!latestReleaseCompletedAt) { updatedAt: string,
latestTestServerBuiltAt: string | null,
testServerCommand: Pick<ServerCommandItem, 'buildRequired' | 'updateAvailable'> | null,
): DeploymentFilterValue {
if (testServerCommand && !testServerCommand.buildRequired && !testServerCommand.updateAvailable) {
return 'deployed';
}
if (!latestTestServerBuiltAt) {
return 'pre-deploy'; return 'pre-deploy';
} }
return getTimeValue(updatedAt) > getTimeValue(latestReleaseCompletedAt) ? 'pre-deploy' : 'deployed'; return getTimeValue(updatedAt) > getTimeValue(latestTestServerBuiltAt) ? '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 ')
);
} }
function resolveSourceChangedAt( function resolveSourceChangedAt(
@@ -446,10 +435,15 @@ function resolveRequestQuestion(request: ChatConversationRequest, messages: Chat
function buildSourceChangeEntry( function buildSourceChangeEntry(
conversationTitle: string, conversationTitle: string,
sessionId: string, sessionId: string,
conversation: {
chatTypeId?: string | null;
contextLabel?: string | null;
},
request: ChatConversationRequest, request: ChatConversationRequest,
messages: ChatMessage[], messages: ChatMessage[],
nextRequest: ChatConversationRequest | undefined, nextRequest: ChatConversationRequest | undefined,
latestReleaseCompletedAt: string | null, testServerCommand: Pick<ServerCommandItem, 'buildRequired' | 'updateAvailable'> | null,
latestTestServerBuiltAt: string | null,
) { ) {
const questionText = resolveRequestQuestion(request, messages); const questionText = resolveRequestQuestion(request, messages);
const answerText = resolveRequestExplanation(request, messages, nextRequest); const answerText = resolveRequestExplanation(request, messages, nextRequest);
@@ -468,6 +462,8 @@ function buildSourceChangeEntry(
id: `${sessionId}:${request.requestId}`, id: `${sessionId}:${request.requestId}`,
sessionId, sessionId,
conversationTitle, conversationTitle,
chatTypeId: String(conversation.chatTypeId ?? '').trim() || null,
chatTypeLabel: String(conversation.contextLabel ?? '').trim(),
requestId: request.requestId, requestId: request.requestId,
requestTitle: createRequestTitle(questionText, request.requestId), requestTitle: createRequestTitle(questionText, request.requestId),
questionText, questionText,
@@ -480,7 +476,7 @@ function buildSourceChangeEntry(
currentSourceFiles, currentSourceFiles,
diffBlocks, diffBlocks,
deploymentStatus: currentSourceFiles.length > 0 deploymentStatus: currentSourceFiles.length > 0
? resolveDeploymentStatus(sourceChangedAt, latestReleaseCompletedAt) ? resolveDeploymentStatus(sourceChangedAt, latestTestServerBuiltAt, testServerCommand)
: 'pre-deploy', : 'pre-deploy',
currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied', currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied',
} satisfies ChatSourceChangeEntry; } satisfies ChatSourceChangeEntry;
@@ -490,9 +486,9 @@ export function ChatSourceChangesPage() {
const [entries, setEntries] = useState<ChatSourceChangeEntry[]>([]); const [entries, setEntries] = useState<ChatSourceChangeEntry[]>([]);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null); const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [deploymentFilter, setDeploymentFilter] = useState<DeploymentFilterValue>('pre-deploy'); const [deploymentSearchCondition, setDeploymentSearchCondition] = useState<DeploymentFilterValue>('pre-deploy');
const [currentSourceFilter, setCurrentSourceFilter] = useState<CurrentSourceFilterValue>('all'); const [currentSourceSearchCondition, setCurrentSourceSearchCondition] = useState<CurrentSourceFilterValue>('applied');
const [latestReleaseCompletedAt, setLatestReleaseCompletedAt] = useState<string | null>(null); const [latestTestServerBuiltAt, setLatestTestServerBuiltAt] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -504,19 +500,9 @@ export function ChatSourceChangesPage() {
setErrorMessage(null); setErrorMessage(null);
try { try {
const planItems = await fetchPlanItems('all'); const serverCommands = await fetchServerCommands();
const testPlanItems = planItems.filter((item) => isTestReleaseTarget(item.releaseTarget)); const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null;
const actionResults = await Promise.allSettled(planItems.map((item) => fetchPlanActionHistories(item.id))); const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null;
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 conversations = await chatGateway.listConversations(); const conversations = await chatGateway.listConversations();
const details = await Promise.allSettled( const details = await Promise.allSettled(
conversations.map(async (conversation) => ({ conversations.map(async (conversation) => ({
@@ -541,17 +527,19 @@ export function ChatSourceChangesPage() {
buildSourceChangeEntry( buildSourceChangeEntry(
conversation.title || '새 대화', conversation.title || '새 대화',
conversation.sessionId, conversation.sessionId,
detail.item,
request, request,
detail.messages, detail.messages,
requests[index + 1], requests[index + 1],
nextLatestReleaseCompletedAt, testServerCommand,
nextLatestTestServerBuiltAt,
), ),
) )
.filter((item): item is ChatSourceChangeEntry => Boolean(item)); .filter((item): item is ChatSourceChangeEntry => Boolean(item));
}) })
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()); .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
setLatestReleaseCompletedAt(nextLatestReleaseCompletedAt); setLatestTestServerBuiltAt(nextLatestTestServerBuiltAt);
setEntries(nextEntries); setEntries(nextEntries);
setSelectedEntryId((previous) => { setSelectedEntryId((previous) => {
if (previous && nextEntries.some((entry) => entry.id === previous)) { if (previous && nextEntries.some((entry) => entry.id === previous)) {
@@ -582,11 +570,11 @@ export function ChatSourceChangesPage() {
const keyword = searchText.trim().toLowerCase(); const keyword = searchText.trim().toLowerCase();
return entries.filter((entry) => { return entries.filter((entry) => {
if (deploymentFilter !== 'all' && entry.deploymentStatus !== deploymentFilter) { if (deploymentSearchCondition !== 'all' && entry.deploymentStatus !== deploymentSearchCondition) {
return false; return false;
} }
if (currentSourceFilter !== 'all' && entry.currentSourceStatus !== currentSourceFilter) { if (currentSourceSearchCondition !== 'all' && entry.currentSourceStatus !== currentSourceSearchCondition) {
return false; return false;
} }
@@ -607,7 +595,7 @@ export function ChatSourceChangesPage() {
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
}); });
}, [currentSourceFilter, deploymentFilter, entries, searchText]); }, [currentSourceSearchCondition, deploymentSearchCondition, entries, searchText]);
const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null; const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null;
@@ -619,7 +607,7 @@ export function ChatSourceChangesPage() {
Codex Live Codex Live
</Title> </Title>
<Paragraph className="chat-source-changes-page__copy"> <Paragraph className="chat-source-changes-page__copy">
test . test .
</Paragraph> </Paragraph>
<Input <Input
value={searchText} value={searchText}
@@ -629,24 +617,25 @@ export function ChatSourceChangesPage() {
}} }}
/> />
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Text type="secondary"></Text>
<Select <Select
value={deploymentFilter} value={deploymentSearchCondition}
options={DEPLOYMENT_FILTER_OPTIONS} options={DEPLOYMENT_FILTER_OPTIONS}
onChange={(value) => { onChange={(value) => {
setDeploymentFilter(value); setDeploymentSearchCondition(value);
}} }}
/> />
<Select <Select
value={currentSourceFilter} value={currentSourceSearchCondition}
options={CURRENT_SOURCE_FILTER_OPTIONS} options={CURRENT_SOURCE_FILTER_OPTIONS}
onChange={(values) => { onChange={(value) => {
setCurrentSourceFilter(values); setCurrentSourceSearchCondition(value);
}} }}
/> />
<Text type="secondary"> <Text type="secondary">
{latestReleaseCompletedAt {latestTestServerBuiltAt
? `최근 test 도메인 배포 완료 시각 기준: ${formatDateTime(latestReleaseCompletedAt)}` ? `현재 TEST 서버 빌드 시각 기준: ${formatDateTime(latestTestServerBuiltAt)}`
: '기록된 test 도메인 배포 완료 이력이 없어 현재 항목은 모두 배포 전으로 표시됩니다.'} : 'TEST 서버 빌드 시각을 읽지 못해 현재 항목은 모두 배포 전으로 표시됩니다.'}
</Text> </Text>
</Space> </Space>
</Space> </Space>
@@ -668,7 +657,7 @@ export function ChatSourceChangesPage() {
description={ description={
entries.length === 0 entries.length === 0
? '소스 수정 흔적이 있는 Codex Live 요청이 아직 없습니다.' ? '소스 수정 흔적이 있는 Codex Live 요청이 아직 없습니다.'
: '현재 검색어 또는 필터에 맞는 변경 이력이 없습니다.' : '현재 검색어 또는 검색조건에 맞는 변경 이력이 없습니다.'
} }
/> />
</Card> </Card>

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { import {
canUseChatType, canUseChatType,
CHAT_PERMISSION_ROLE_LABELS, CHAT_PERMISSION_ROLE_LABELS,
deleteChatType,
resolveCurrentChatPermissionRoles, resolveCurrentChatPermissionRoles,
upsertChatType, upsertChatType,
useChatTypeRegistry, useChatTypeRegistry,
@@ -49,10 +50,12 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
export function ChatTypeManagementPage() { export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes } = useChatTypeRegistry(); const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null); const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatTypeFormValue>(); const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
@@ -104,7 +107,7 @@ export function ChatTypeManagementPage() {
setDetailMode('list'); setDetailMode('list');
}; };
const handleDelete = () => { const handleDelete = async () => {
if (!selectedChatType) { if (!selectedChatType) {
return; return;
} }
@@ -113,13 +116,22 @@ export function ChatTypeManagementPage() {
return; return;
} }
const nextChatTypes = chatTypes.filter((item) => item.id !== selectedChatType.id); const nextChatTypes = deleteChatType(chatTypes, selectedChatType.id);
setChatTypes(nextChatTypes); setIsSaving(true);
setSelectedChatTypeId(nextChatTypes[0]?.id ?? null); setSaveErrorMessage('');
setIsCreating(false);
setDetailMode('list'); try {
form.resetFields(); const savedChatTypes = await setChatTypes(nextChatTypes);
form.setFieldsValue(EMPTY_FORM_VALUE); 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) { if (!hasAccess) {
@@ -148,9 +160,11 @@ export function ChatTypeManagementPage() {
} }
> >
<div className="chat-type-management-page__list"> <div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header"> <div className="chat-type-management-page__list-header">
<Title level={5}> </Title> <Title level={5}> </Title>
<Text type="secondary">{chatTypes.length}</Text> <Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}`}</Text>
</div> </div>
{chatTypes.length > 0 ? ( {chatTypes.length > 0 ? (
@@ -170,13 +184,14 @@ export function ChatTypeManagementPage() {
openDetail(item.id); openDetail(item.id);
}} }}
actions={[ actions={[
<Button <Button
key="edit" key="edit"
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={(event) => { disabled={isSaving}
event.stopPropagation(); onClick={(event) => {
openDetail(item.id); event.stopPropagation();
openDetail(item.id);
}} }}
/>, />,
]} ]}
@@ -215,7 +230,7 @@ export function ChatTypeManagementPage() {
extra={ extra={
<Space wrap> <Space wrap>
{!isCreating && selectedChatType ? ( {!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}> <Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button> </Button>
) : null} ) : null}
@@ -226,6 +241,8 @@ export function ChatTypeManagementPage() {
} }
> >
<div className="chat-type-management-page__editor"> <div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header"> <div className="chat-type-management-page__list-header">
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title> <Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
<Text type="secondary"> , .</Text> <Text type="secondary"> , .</Text>
@@ -235,14 +252,22 @@ export function ChatTypeManagementPage() {
layout="vertical" layout="vertical"
form={form} form={form}
initialValues={EMPTY_FORM_VALUE} initialValues={EMPTY_FORM_VALUE}
onFinish={(values) => { onFinish={async (values) => {
const nextChatTypes = upsertChatType(chatTypes, values); const nextChatTypes = upsertChatType(chatTypes, values);
setChatTypes(nextChatTypes); setIsSaving(true);
setSaveErrorMessage('');
const savedChatType = nextChatTypes.find((item) => item.id === values.id || item.name === values.name); try {
setIsCreating(false); const savedChatTypes = await setChatTypes(nextChatTypes);
setSelectedChatTypeId(savedChatType?.id ?? null); const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
setDetailMode('detail'); setIsCreating(false);
setSelectedChatTypeId(savedChatType?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}} }}
> >
<Form.Item name="id" hidden> <Form.Item name="id" hidden>
@@ -276,10 +301,12 @@ export function ChatTypeManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" /> <Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item> </Form.Item>
<Space wrap> <Space wrap>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'} {isCreating ? '등록' : '수정 저장'}
</Button> </Button>
<Button onClick={openCreateForm}> </Button> <Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space> </Space>
</Form> </Form>
</div> </div>

View File

@@ -10,6 +10,10 @@
gap: 2px; gap: 2px;
} }
.header-message-center__tabs {
width: 100%;
}
.header-message-center__loading { .header-message-center__loading {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,9 +1,10 @@
import { BellOutlined, ReloadOutlined } from '@ant-design/icons'; 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 { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
type NotificationMessageItem, type NotificationMessageItem,
type NotificationMessageListStatus,
type NotificationMessagePriority, type NotificationMessagePriority,
} from './notificationApi'; } from './notificationApi';
import { useNotificationController } from './chatV2/hooks/useNotificationController'; import { useNotificationController } from './chatV2/hooks/useNotificationController';
@@ -142,6 +143,8 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
const [draggingMessageId, setDraggingMessageId] = useState<number | null>(null); const [draggingMessageId, setDraggingMessageId] = useState<number | null>(null);
const [dragOffsetX, setDragOffsetX] = useState(0); const [dragOffsetX, setDragOffsetX] = useState(0);
const { const {
listStatus,
setListStatus,
unreadCount, unreadCount,
detailOpen, detailOpen,
setDetailOpen, setDetailOpen,
@@ -159,6 +162,11 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
handleDeleteMessage: deleteMessage, handleDeleteMessage: deleteMessage,
} = useNotificationController(drawerOpen); } = useNotificationController(drawerOpen);
const listStatusOptions: Array<{ value: NotificationMessageListStatus; label: string }> = [
{ value: 'unread', label: `안읽음 ${unreadCount}` },
{ value: 'all', label: '전체' },
];
const resetSwipeState = () => { const resetSwipeState = () => {
swipeStartXRef.current = null; swipeStartXRef.current = null;
swipeStartYRef.current = null; swipeStartYRef.current = null;
@@ -333,10 +341,20 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
> >
<Space direction="vertical" size={12} style={{ width: '100%' }}> <Space direction="vertical" size={12} style={{ width: '100%' }}>
<div className="header-message-center__summary"> <div className="header-message-center__summary">
<Text strong> {unreadCount}</Text> <Text strong>{listStatus === 'unread' ? `안읽음 메시지 ${unreadCount}` : '전체 알림 목록'}</Text>
<Text type="secondary"> 30 .</Text> <Text type="secondary"> 30 .</Text>
</div> </div>
<Segmented
block
value={listStatus}
options={listStatusOptions}
className="header-message-center__tabs"
onChange={(value) => {
setListStatus(value as NotificationMessageListStatus);
}}
/>
{listError ? <Alert type="error" showIcon message={listError} /> : null} {listError ? <Alert type="error" showIcon message={listError} /> : null}
{listLoading ? ( {listLoading ? (

View File

@@ -30,6 +30,10 @@
.app-chat-panel__preview-modal.ant-modal { .app-chat-panel__preview-modal.ant-modal {
z-index: 1400; z-index: 1400;
max-width: 100vw;
margin: 0;
top: 0;
padding-bottom: 0;
} }
.app-chat-panel__preview-modal.ant-modal .ant-modal-mask { .app-chat-panel__preview-modal.ant-modal .ant-modal-mask {
@@ -99,6 +103,15 @@
height: 100%; 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 { .app-chat-panel__conversation-shell {
position: relative; position: relative;
display: flex; display: flex;
@@ -116,8 +129,10 @@
.app-chat-panel__conversation-list { .app-chat-panel__conversation-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 220px; flex: 0 0 280px;
min-width: 220px; width: 280px;
min-width: 280px;
max-width: 280px;
min-height: 0; min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.14); border-right: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.72); background: rgba(248, 250, 252, 0.72);
@@ -136,6 +151,19 @@
padding: 8px 8px 0; 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 { .app-chat-panel__conversation-list-body {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -220,7 +248,7 @@
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
padding: 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); background: rgba(255, 255, 255, 0.82);
border-radius: 14px; border-radius: 14px;
transition: transition:
@@ -231,13 +259,13 @@
} }
.app-chat-panel__conversation-item--active { .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); background: rgba(239, 246, 255, 0.95);
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08); box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08);
} }
.app-chat-panel__conversation-item--processing { .app-chat-panel__conversation-item--processing {
border-color: rgba(245, 158, 11, 0.34); border-color: rgba(245, 158, 11, 0.14);
background: background:
linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%), linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%),
#fff; #fff;
@@ -247,7 +275,7 @@
} }
.app-chat-panel__conversation-item--unread { .app-chat-panel__conversation-item--unread {
border-color: rgba(37, 99, 235, 0.7); border-color: rgba(37, 99, 235, 0.18);
background: 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%), 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; #fff;
@@ -257,7 +285,7 @@
} }
.app-chat-panel__conversation-item--unread-section { .app-chat-panel__conversation-item--unread-section {
border-color: rgba(37, 99, 235, 0.82); border-color: rgba(37, 99, 235, 0.2);
background: background:
linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%), linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%),
#fff; #fff;
@@ -296,7 +324,7 @@
} }
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { .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: 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%), 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; #fff;
@@ -528,9 +556,10 @@
.app-chat-panel__conversation-main { .app-chat-panel__conversation-main {
display: flex; display: flex;
flex: 1; flex: 1 1 0%;
flex-direction: column; flex-direction: column;
width: 100%; width: auto;
max-width: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
position: relative; position: relative;
@@ -793,7 +822,7 @@
.app-chat-panel__title-group { .app-chat-panel__title-group {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
@@ -1206,6 +1235,14 @@
width: 100%; 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--codex {
--app-chat-message-fade-end: rgba(248, 251, 255, 0.96); --app-chat-message-fade-end: rgba(248, 251, 255, 0.96);
margin-left: 8px; margin-left: 8px;
@@ -1325,11 +1362,37 @@
max-width: 100%; max-width: 100%;
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; 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 { .app-chat-message__body--collapsed {
position: relative; position: relative;
max-height: calc(1.45em * 6); max-height: calc(1.45em * 6);
@@ -1370,7 +1433,7 @@
gap: 8px; gap: 8px;
width: 100%; width: 100%;
max-width: none; max-width: none;
padding: 8px 10px 10px; padding: 8px 0 10px;
border: 1px solid rgba(148, 163, 184, 0.22); 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)); 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); 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--codex .app-chat-preview-card,
.app-chat-message-stack--system .app-chat-preview-card { .app-chat-message-stack--system .app-chat-preview-card {
margin-left: 8px; margin-left: 0;
margin-right: 24px; margin-right: 0;
} }
.app-chat-message-stack--user .app-chat-preview-card { .app-chat-message-stack--user .app-chat-preview-card {
margin-left: 24px; margin-left: 0;
margin-right: 8px; margin-right: 0;
}
.app-chat-message-stack--artifact-only .app-chat-preview-card {
margin-left: 0;
margin-right: 0;
} }
.app-chat-preview-card__header { .app-chat-preview-card__header {
@@ -1405,6 +1473,13 @@
padding: 8px 10px; 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 { .app-chat-preview-card--collapsed .app-chat-preview-card__header {
border: 1px solid rgba(148, 163, 184, 0.22); 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)); background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
@@ -1429,6 +1504,8 @@
margin-top: 1px; margin-top: 1px;
color: #475569; color: #475569;
background: rgba(226, 232, 240, 0.9); background: rgba(226, 232, 240, 0.9);
border-radius: 999px;
font-size: 12px;
} }
.app-chat-preview-card__titles { .app-chat-preview-card__titles {
@@ -1440,12 +1517,15 @@
overflow: hidden; overflow: hidden;
} }
.app-chat-preview-card__label,
.app-chat-preview-card__kind,
.app-chat-preview-card__label.ant-typography, .app-chat-preview-card__label.ant-typography,
.app-chat-preview-card__kind.ant-typography { .app-chat-preview-card__kind.ant-typography {
margin: 0; margin: 0;
max-width: 100%; max-width: 100%;
} }
.app-chat-preview-card__label,
.app-chat-preview-card__label.ant-typography { .app-chat-preview-card__label.ant-typography {
font-size: 12px; font-size: 12px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -1455,10 +1535,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.app-chat-preview-card__kind,
.app-chat-preview-card__kind.ant-typography { .app-chat-preview-card__kind.ant-typography {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #64748b;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1471,6 +1553,13 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.app-chat-preview-card__action.ant-btn {
height: 22px;
min-width: 22px;
padding: 0;
color: #475569;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.app-chat-panel { .app-chat-panel {
height: 100%; height: 100%;
@@ -1496,6 +1585,7 @@
} }
.app-chat-panel__conversation-shell, .app-chat-panel__conversation-shell,
.app-chat-panel__stack--chat,
.app-chat-panel__conversation-main, .app-chat-panel__conversation-main,
.app-chat-panel__conversation-view, .app-chat-panel__conversation-view,
.app-chat-panel__conversation-view-inner, .app-chat-panel__conversation-view-inner,
@@ -1527,9 +1617,15 @@
.app-chat-message-stack--codex .app-chat-preview-card, .app-chat-message-stack--codex .app-chat-preview-card,
.app-chat-message-stack--system .app-chat-preview-card, .app-chat-message-stack--system .app-chat-preview-card,
.app-chat-message-stack--user .app-chat-preview-card { .app-chat-message-stack--user .app-chat-preview-card {
margin-left: 4px; margin-left: 0;
margin-right: 4px; margin-right: 0;
max-width: calc(100% - 8px); 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 { .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 { .app-chat-preview-card__body {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -1545,6 +1656,84 @@
width: 100%; 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 { .app-chat-panel__preview-rich {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -1556,6 +1745,13 @@
width: 100%; 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 { .app-chat-panel__preview-rich .previewer-ui__editor {
border-color: rgba(15, 23, 42, 0.58); border-color: rgba(15, 23, 42, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -1583,6 +1779,10 @@
color: #e2e8f0; 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 { .app-chat-panel__preview-rich--markdown {
padding: 4px 2px 0; padding: 4px 2px 0;
} }
@@ -1762,7 +1962,7 @@
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
min-height: 88px; min-height: 88px;
padding: 8px 14px 12px; padding: 8px 76px 16px 14px;
} }
.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea { .app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea {
@@ -1806,6 +2006,37 @@
display: none; 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 { .app-chat-panel__composer-attachment-strip {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1943,6 +2174,7 @@
.app-chat-panel__preview-stage > * { .app-chat-panel__preview-stage > * {
width: 100%; width: 100%;
height: 100%;
} }
.app-chat-panel__preview-loading { .app-chat-panel__preview-loading {
@@ -1973,6 +2205,10 @@
pointer-events: none; pointer-events: none;
} }
.app-chat-panel__conversation-view-inner.is-busy {
user-select: auto;
}
.app-chat-panel__conversation-loading { .app-chat-panel__conversation-loading {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1994,6 +2230,34 @@
text-align: center; 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-image,
.app-chat-panel__preview-video, .app-chat-panel__preview-video,
.app-chat-panel__preview-frame { .app-chat-panel__preview-frame {
@@ -2034,13 +2298,50 @@
} }
.app-chat-panel__preview-modal .ant-modal-body { .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 { .app-chat-panel__preview-modal {
z-index: 1600; 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 { .app-chat-panel__delete-confirm-modal {
z-index: 1700 !important; z-index: 1700 !important;
} }
@@ -2052,13 +2353,59 @@
.app-chat-panel__preview-modal-body { .app-chat-panel__preview-modal-body {
display: flex; display: flex;
flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 0;
min-height: 0;
overflow: hidden;
} }
.app-chat-panel__preview-modal-meta { .app-chat-panel__preview-modal-meta {
display: flex; display: flex;
justify-content: flex-start; 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 { .app-chat-panel__connection-dot--connecting {
@@ -2429,6 +2776,14 @@
} }
@media (max-width: 768px) { @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 { .app-chat-runtime {
overflow: hidden; overflow: hidden;
} }
@@ -2560,6 +2915,63 @@
height: 100%; 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 { .chat-v2__conversation-list .ant-list-item {
padding: 0; padding: 0;
border-block-end: 0; border-block-end: 0;
@@ -2604,6 +3016,13 @@
font-size: 13px; font-size: 13px;
} }
@media (max-width: 1180px) {
.chat-v2__pane--list {
flex-basis: auto;
max-width: none;
}
}
@media (min-width: 1181px) { @media (min-width: 1181px) {
.app-chat-panel { .app-chat-panel {
background: background:
@@ -2640,18 +3059,19 @@
} }
.app-chat-panel__conversation-item { .app-chat-panel__conversation-item {
border-color: rgba(148, 163, 184, 0.14); border-color: transparent;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
} }
.app-chat-panel__conversation-item--active { .app-chat-panel__conversation-item--active {
border-color: rgba(100, 116, 139, 0.26); border-color: transparent;
background: rgba(248, 250, 252, 0.98); background: rgba(248, 250, 252, 0.98);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
} }
.app-chat-panel__conversation-item--unread { .app-chat-panel__conversation-item--unread {
border-color: rgba(148, 163, 184, 0.28); border-color: transparent;
background: background:
linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%), linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%),
#fff; #fff;
@@ -2661,7 +3081,7 @@
} }
.app-chat-panel__conversation-item--unread-section { .app-chat-panel__conversation-item--unread-section {
border-color: rgba(148, 163, 184, 0.32); border-color: transparent;
background: background:
linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%), linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%),
#fff; #fff;
@@ -2679,7 +3099,7 @@
} }
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread {
border-color: rgba(100, 116, 139, 0.34); border-color: transparent;
background: 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%), 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; #fff;
@@ -2689,7 +3109,7 @@
} }
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section { .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: 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%), 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; #fff;

View File

@@ -3,12 +3,12 @@ import {
BellFilled, BellFilled,
BellOutlined, BellOutlined,
CloseOutlined, CloseOutlined,
CopyOutlined,
DownloadOutlined, DownloadOutlined,
EditOutlined, EditOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
FullscreenOutlined, FullscreenOutlined,
LinkOutlined,
MessageOutlined, MessageOutlined,
PaperClipOutlined, PaperClipOutlined,
PlusOutlined, PlusOutlined,
@@ -23,7 +23,6 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { useAppConfig } from './appConfig'; import { useAppConfig } from './appConfig';
import { chatConnectionGateway, chatGateway } from './chatV2'; import { chatConnectionGateway, chatGateway } from './chatV2';
import { emitChatConversationsUpdated } from './chatV2/data/chatClientEvents';
import { useConversationComposerController } from './chatV2/hooks/useConversationComposerController'; import { useConversationComposerController } from './chatV2/hooks/useConversationComposerController';
import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController'; import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController';
import { useConversationListController } from './chatV2/hooks/useConversationListController'; import { useConversationListController } from './chatV2/hooks/useConversationListController';
@@ -33,12 +32,15 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController'; import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl'; import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import { import {
ChatConversationView, ChatConversationView,
ChatRuntimeDashboard, ChatRuntimeDashboard,
copyPreviewContent,
copyText, copyText,
createActivityLogPlaceholder, createActivityLogPlaceholder,
createChatMessage, createChatMessage,
@@ -47,6 +49,7 @@ import {
getStoredChatSessionLastTypeId, getStoredChatSessionLastTypeId,
isPreparingChatReplyText, isPreparingChatReplyText,
setStoredChatSessionLastTypeId, setStoredChatSessionLastTypeId,
sortChatConversationSummaries,
upsertChatMessage, upsertChatMessage,
useErrorLogs, useErrorLogs,
} from './mainChatPanel'; } from './mainChatPanel';
@@ -62,6 +65,7 @@ import type {
ChatViewContext, ChatViewContext,
MainChatPanelProps, MainChatPanelProps,
} from './mainChatPanel/types'; } from './mainChatPanel/types';
import { buildChatPath } from './routes';
import './MainChatPanel.css'; import './MainChatPanel.css';
import './MainChatPanel.hotfix.css'; import './MainChatPanel.hotfix.css';
@@ -209,7 +213,7 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig
} }
function getConversationLatestActivityTime(item: ChatConversationSummary) { 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; const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0;
return Number.isFinite(parsedTime) ? parsedTime : 0; return Number.isFinite(parsedTime) ? parsedTime : 0;
@@ -446,7 +450,7 @@ function extractPreviewItems(messages: ChatMessage[]) {
const orderedMessages = [...messages].reverse(); const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => { orderedMessages.forEach((message) => {
const matches = message.text.match(urlPattern) ?? []; const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
matches.forEach((matchedUrl) => { matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(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 selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const requestedSessionId = getSessionIdFromSearch(location.search); const requestedSessionId = getSessionIdFromSearch(location.search);
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search); const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
const requestedChatView = getRequestedChatViewFromSearch(location.search);
const [activeSessionId, setActiveSessionId] = useState(''); const [activeSessionId, setActiveSessionId] = useState('');
const sessionMessageCacheRef = useRef<Map<string, ChatMessage[]>>(new Map()); const sessionMessageCacheRef = useRef<Map<string, ChatMessage[]>>(new Map());
const [isMobileConversationView, setIsMobileConversationView] = useState(false); const [isMobileConversationView, setIsMobileConversationView] = useState(false);
@@ -621,15 +626,22 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false); const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [activeView, setActiveView] = useState<ChatPanelView>(initialView === 'errors' ? 'errors' : 'chat'); const [activeView, setActiveView] = useState<ChatPanelView>(() => {
if (requestedChatView) {
return requestedChatView;
}
return initialView === 'errors' ? 'errors' : 'chat';
});
const [copiedMessageId, setCopiedMessageId] = useState<number | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<number | null>(null);
const [requestItemsState, setRequestItemsState] = useState<ChatConversationRequest[]>([]); const [requestItemsState, setRequestItemsState] = useState<ChatConversationRequest[]>([]);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null); const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null);
const [isConversationContentLoading, setIsConversationContentLoading] = useState(true); const [isConversationContentLoading, setIsConversationContentLoading] = useState(true);
const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.'); const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.');
const [conversationRoomReloadKey, setConversationRoomReloadKey] = useState(0);
const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false); const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false); const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [, setOldestLoadedMessageId] = useState<number | null>(null); const [oldestLoadedMessageId, setOldestLoadedMessageId] = useState<number | null>(null);
const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false); const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false);
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false); const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
@@ -728,19 +740,62 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}; };
const handleCreateConversation = async () => { const handleCreateConversation = async () => {
const sessionId = createConversationSessionId(); 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 { try {
const item = await chatGateway.createConversation({ const item = await chatGateway.createConversation({
sessionId, sessionId,
title: '새 대화', title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name, contextLabel: selectedChatType?.name,
contextDescription: selectedChatType?.description, contextDescription: selectedChatType?.description,
notifyOffline: true, notifyOffline: true,
}); });
setConversationItems((previous) => [item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]); setConversationItems((previous) =>
openConversationSession(item.sessionId); sortChatConversationSummaries([item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]),
);
} catch (error) { } 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 : '새 대화를 만들지 못했습니다.'); messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
} }
}; };
@@ -770,16 +825,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} }
setConversationItems((previous) => setConversationItems((previous) =>
previous.map((item) => sortChatConversationSummaries(
item.sessionId === sessionId previous.map((item) =>
? { item.sessionId === sessionId
...item, ? {
title: buildConversationTitleFromRequestText(text, item.title), ...item,
lastMessagePreview: nextPreview, title: buildConversationTitleFromRequestText(text, item.title),
lastMessageAt: requestedAt, lastMessagePreview: nextPreview,
updatedAt: requestedAt, lastMessageAt: requestedAt,
} updatedAt: requestedAt,
: item, }
: item,
),
), ),
); );
}; };
@@ -789,10 +846,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const exists = previous.some((item) => item.sessionId === sessionId); const exists = previous.some((item) => item.sessionId === sessionId);
if (!exists) { 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); sessionMessageCacheRef.current.set(sessionId, detail.messages);
@@ -980,37 +1039,39 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const responseTimestamp = new Date().toISOString(); const responseTimestamp = new Date().toISOString();
setConversationItems((previous) => setConversationItems((previous) =>
previous.map((item) => sortChatConversationSummaries(
item.sessionId === sessionId previous.map((item) =>
? { item.sessionId === sessionId
...item, ? {
title: ...item,
incomingMessage.author === 'user' title:
? buildConversationTitleFromRequestText(incomingMessage.text, item.title) incomingMessage.author === 'user'
: item.title, ? buildConversationTitleFromRequestText(incomingMessage.text, item.title)
lastMessagePreview: createConversationPreviewText(incomingMessage.text), : item.title,
lastMessageAt: responseTimestamp, lastMessagePreview: createConversationPreviewText(incomingMessage.text),
updatedAt: responseTimestamp, lastMessageAt: responseTimestamp,
hasUnreadResponse: updatedAt: responseTimestamp,
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse, hasUnreadResponse:
currentRequestId: hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
incomingMessage.author === 'codex' && incomingMessage.clientRequestId currentRequestId:
? null incomingMessage.author === 'codex' && incomingMessage.clientRequestId
: item.currentRequestId, ? null
currentJobStatus: : item.currentRequestId,
incomingMessage.author === 'codex' && incomingMessage.clientRequestId currentJobStatus:
? null incomingMessage.author === 'codex' && incomingMessage.clientRequestId
: item.currentJobStatus, ? null
currentJobMessage: : item.currentJobStatus,
incomingMessage.author === 'codex' && incomingMessage.clientRequestId currentJobMessage:
? null incomingMessage.author === 'codex' && incomingMessage.clientRequestId
: item.currentJobMessage, ? null
currentQueueSize: : item.currentJobMessage,
incomingMessage.author === 'codex' && incomingMessage.clientRequestId currentQueueSize:
? 0 incomingMessage.author === 'codex' && incomingMessage.clientRequestId
: item.currentQueueSize, ? 0
} : item.currentQueueSize,
: item, }
: item,
),
), ),
); );
@@ -1033,25 +1094,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; 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({ const { socketRef, connectionState } = chatConnectionGateway.useConnection({
sessionId: activeSessionId, sessionId: activeSessionId,
currentContext, currentContext,
@@ -1167,8 +1209,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}); });
const { loadOlderMessages } = useConversationRoomController({ const { loadOlderMessages } = useConversationRoomController({
activeSessionId, activeSessionId,
oldestLoadedMessageId,
reloadKey: conversationRoomReloadKey,
connectionState, connectionState,
shouldBlockConversationWhileLoading,
captureViewportRestoreSnapshot, captureViewportRestoreSnapshot,
sessionMessageCacheRef, sessionMessageCacheRef,
messagesRef, messagesRef,
@@ -1245,7 +1288,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewItems, previewItems,
selectedChatTypeId: selectedChatType?.id ?? null, selectedChatTypeId: selectedChatType?.id ?? null,
composerRef, composerRef,
sessionMessageCacheRef,
setActiveSystemStatus, setActiveSystemStatus,
setComposerAttachments, setComposerAttachments,
setCopiedMessageId, setCopiedMessageId,
@@ -1254,6 +1296,53 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsSystemStatusPending, setIsSystemStatusPending,
setMessages, 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 markConversationReadLocally = (sessionId: string) => {
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
@@ -1321,7 +1410,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span> <span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
</span> </span>
<span className="app-chat-panel__conversation-item-time"> <span className="app-chat-panel__conversation-item-time">
{formatConversationListTimestamp(item.lastMessageAt || item.updatedAt)} {formatConversationListTimestamp(item.lastMessageAt || item.createdAt)}
</span> </span>
</span> </span>
<span className="app-chat-panel__conversation-item-id">{item.sessionId}</span> <span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
@@ -1360,6 +1449,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}; };
const replaceChatSessionInUrl = (sessionId: string) => { const replaceChatSessionInUrl = (sessionId: string) => {
if (location.pathname !== buildChatPath('live')) {
return;
}
const nextSessionId = sessionId.trim(); const nextSessionId = sessionId.trim();
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const currentSessionId = searchParams.get('sessionId')?.trim() ?? ''; 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(() => { const clearRequestedRuntimeLogInUrl = useCallback(() => {
if (location.pathname !== buildChatPath('live')) {
return;
}
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) { if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) {
@@ -1410,6 +1533,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const openConversationSession = (sessionId: string) => { const openConversationSession = (sessionId: string) => {
replaceChatSessionInUrl(sessionId); replaceChatSessionInUrl(sessionId);
const now = new Date().toISOString();
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
const hasCachedMessages = cachedMessages.length > 0;
if (sessionId === activeSessionId && !isConversationPaneClosed) { if (sessionId === activeSessionId && !isConversationPaneClosed) {
if (isMobileViewport) { if (isMobileViewport) {
@@ -1419,24 +1545,56 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; return;
} }
chatConnectionGateway.resetLastReceivedEventId(sessionId);
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.'); setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
setIsConversationContentLoading(shouldBlockConversationWhileLoading(sessionId)); setIsConversationContentLoading(true);
setIsDeferringAuxiliaryChatRequests(true); setIsDeferringAuxiliaryChatRequests(true);
setHasOlderMessages(false); setHasOlderMessages(false);
setOldestLoadedMessageId(null); setOldestLoadedMessageId(null);
setIsLoadingOlderMessages(false); setIsLoadingOlderMessages(false);
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
setShowScrollToBottom(false); 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); setActiveSessionId(sessionId);
if (sessionId === activeSessionId) {
setConversationRoomReloadKey((previous) => previous + 1);
}
setIsConversationPaneClosed(false); setIsConversationPaneClosed(false);
setActiveView('chat');
if (isMobileViewport) { if (isMobileViewport) {
setIsMobileConversationView(true); setIsMobileConversationView(true);
} }
setMessages(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId)); setMessages(hasCachedMessages ? cachedMessages : []);
setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId));
setActivePreviewId(null); setActivePreviewId(null);
setIsPreviewModalOpen(false); setIsPreviewModalOpen(false);
setActiveSystemStatus(null); setActiveSystemStatus(null);
@@ -1486,10 +1644,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
conversationItemsRef.current = conversationItems; conversationItemsRef.current = conversationItems;
}, [conversationItems]); }, [conversationItems]);
useEffect(() => {
emitChatConversationsUpdated(conversationItems);
}, [conversationItems]);
useEffect(() => { useEffect(() => {
messagesRef.current = messages; messagesRef.current = messages;
}, [messages]); }, [messages]);
@@ -1507,6 +1661,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [messages]); }, [messages]);
useEffect(() => { useEffect(() => {
if (activeView !== 'chat') {
return;
}
if ( if (
!isActiveChatSessionInForeground({ !isActiveChatSessionInForeground({
sessionId: activeSessionId, sessionId: activeSessionId,
@@ -1718,7 +1876,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [activeSessionId, selectedChatTypeId]); }, [activeSessionId, selectedChatTypeId]);
useEffect(() => { useEffect(() => {
const nextView = initialView === 'errors' ? 'errors' : 'chat'; const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
if (nextView === 'errors' && !hasAccess) { if (nextView === 'errors' && !hasAccess) {
setActiveView('chat'); setActiveView('chat');
@@ -1726,7 +1884,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} }
setActiveView(nextView); setActiveView(nextView);
}, [hasAccess, initialView]); }, [hasAccess, initialView, requestedChatView]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1762,6 +1920,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [activeConversation?.sessionId, activeConversation?.title]); }, [activeConversation?.sessionId, activeConversation?.title]);
useEffect(() => { useEffect(() => {
if (location.pathname !== buildChatPath('live')) {
handledRequestedSessionIdRef.current = '';
return;
}
if (!requestedSessionId) { if (!requestedSessionId) {
handledRequestedSessionIdRef.current = ''; handledRequestedSessionIdRef.current = '';
return; return;
@@ -1771,12 +1934,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; return;
} }
const requestedConversationExists = conversationItems.some((item) => item.sessionId === requestedSessionId);
if (!requestedConversationExists) {
return;
}
if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) { if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) {
return; return;
} }
@@ -1787,14 +1944,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
if (isMobileViewport && !isConversationPaneClosed) { if (isMobileViewport && !isConversationPaneClosed) {
setIsMobileConversationView(true); setIsMobileConversationView(true);
} }
if (!isConversationPaneClosed) {
setActiveView('chat');
}
return; return;
} }
openConversationSession(requestedSessionId); openConversationSession(requestedSessionId);
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, requestedSessionId]); }, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]);
useEffect(() => { useEffect(() => {
if (requestedSessionId) { if (requestedSessionId) {
@@ -1839,7 +1993,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
if (!activeSessionId.trim()) { if (!activeSessionId.trim()) {
void chatGateway.listConversations().then((items) => { void chatGateway.listConversations().then((items) => {
if (!isCancelled) { if (!isCancelled) {
setConversationItems(items); setConversationItems(sortChatConversationSummaries(items));
} }
}).catch(() => { }).catch(() => {
// 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다. // 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다.
@@ -1879,13 +2033,23 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} }
const latestMessage = getLatestConversationPreviewMessage(messages); const latestMessage = getLatestConversationPreviewMessage(messages);
const nextTitle = buildConversationTitleFromMessages(messages, item.title);
const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview; 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 { return {
...item, ...item,
title: buildConversationTitleFromMessages(messages, item.title), title: nextTitle,
lastMessagePreview: nextPreview, lastMessagePreview: nextPreview,
lastMessageAt: latestMessage ? new Date().toISOString() : item.lastMessageAt, lastMessageAt: nextLastMessageAt,
}; };
}), }),
); );
@@ -1896,6 +2060,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; return;
} }
if (activeView !== 'chat') {
return;
}
if ( if (
!isActiveChatSessionInForeground({ !isActiveChatSessionInForeground({
sessionId: activeConversation.sessionId, sessionId: activeConversation.sessionId,
@@ -2057,6 +2225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}`} }`}
aria-label="대화 보기" aria-label="대화 보기"
onClick={() => { onClick={() => {
replaceChatViewInUrl('chat');
setActiveView('chat'); setActiveView('chat');
setIsTitleClusterOpen(false); setIsTitleClusterOpen(false);
}} }}
@@ -2070,6 +2239,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}`} }`}
aria-label="런타임 보기" aria-label="런타임 보기"
onClick={() => { onClick={() => {
replaceChatViewInUrl('runtime');
setActiveView('runtime'); setActiveView('runtime');
setIsTitleClusterOpen(false); setIsTitleClusterOpen(false);
}} }}
@@ -2084,6 +2254,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}`} }`}
aria-label="에러 로그 보기" aria-label="에러 로그 보기"
onClick={() => { onClick={() => {
replaceChatViewInUrl('errors');
setActiveView('errors'); setActiveView('errors');
setIsTitleClusterOpen(false); setIsTitleClusterOpen(false);
}} }}
@@ -2183,7 +2354,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}`} }`}
styles={{ body: { height: '100%' } }} styles={{ body: { height: '100%' } }}
> >
<div className="app-chat-panel__stack"> <div className={`app-chat-panel__stack${activeView === 'chat' ? ' app-chat-panel__stack--chat' : ''}`}>
{activeView === 'chat' ? ( {activeView === 'chat' ? (
<> <>
{!isMobileViewport || !isMobileConversationView || isConversationPaneClosed ? ( {!isMobileViewport || !isMobileConversationView || isConversationPaneClosed ? (
@@ -2298,7 +2469,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
text: item.userText, text: item.userText,
}))} }))}
chatTypeOptions={chatTypeOptions} 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} isResourceStripOpen={isResourceStripOpen}
isComposerDisabled={!selectedChatType} isComposerDisabled={!selectedChatType}
isComposerAttachmentUploading={isComposerAttachmentUploading} isComposerAttachmentUploading={isComposerAttachmentUploading}
@@ -2314,6 +2485,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
onSelectChatType={setSelectedChatTypeId} onSelectChatType={setSelectedChatTypeId}
onSend={handleSend} onSend={handleSend}
onSendImmediate={handleSendImmediate} onSendImmediate={handleSendImmediate}
onClearDraft={() => {
setDraft('');
}}
onToggleResourceStrip={() => { onToggleResourceStrip={() => {
setIsResourceStripOpen((current) => !current); setIsResourceStripOpen((current) => !current);
}} }}
@@ -2322,10 +2496,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setShowScrollToBottom(false); setShowScrollToBottom(false);
scrollViewportToBottom(); scrollViewportToBottom();
}} }}
onOpenPreview={(previewId) => { onOpenPreview={openPreviewModal}
setActivePreviewId(previewId);
setIsPreviewModalOpen(true);
}}
onCopyMessage={(message) => { onCopyMessage={(message) => {
void handleCopyMessage(message); void handleCopyMessage(message);
}} }}
@@ -2411,8 +2582,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
> >
<Space direction="vertical" size={8}> <Space direction="vertical" size={8}>
<Text> <Text>
{pendingContextConfirm?.includedContextCount ?? 0} , {appConfig.chat.maxContextMessages} ,
{pendingContextConfirm?.omittedContextCount ?? 0} . {appConfig.chat.maxContextChars} . {' '}
{pendingContextConfirm?.includedContextCount ?? 0} , {' '}
{pendingContextConfirm?.omittedContextCount ?? 0} .
</Text> </Text>
<Text type="secondary"> <Text type="secondary">
. .
@@ -2424,25 +2597,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
title={activePreview ? `${activePreview.label} preview` : 'preview'} title={activePreview ? `${activePreview.label} preview` : 'preview'}
footer={ footer={
activePreview ? ( activePreview ? (
<Space wrap> <div className="app-chat-panel__preview-modal-footer">
<Button <Space wrap>
href={activePreview.url} <Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
target="_blank" <Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
rel="noreferrer" </Space>
icon={<LinkOutlined />} </div>
>
</Button>
<Button href={activePreview.url} download icon={<DownloadOutlined />}>
</Button>
</Space>
) : null ) : null
} }
onCancel={() => { onCancel={() => {
setIsPreviewModalOpen(false); setIsPreviewModalOpen(false);
}} }}
width={960} width="100vw"
zIndex={1600} zIndex={1600}
className="app-chat-panel__preview-modal" className="app-chat-panel__preview-modal"
> >

View File

@@ -3,7 +3,6 @@ import {
BellOutlined, BellOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CopyOutlined, CopyOutlined,
DownloadOutlined,
FileMarkdownOutlined, FileMarkdownOutlined,
LoadingOutlined, LoadingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
@@ -23,13 +22,12 @@ import {
InputNumber, InputNumber,
Layout, Layout,
Modal, Modal,
Progress,
Select, Select,
Segmented, Segmented,
Space, Space,
Typography, Typography,
} from 'antd'; } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { fetchPlanItems } from '../../features/planBoard/api'; import { fetchPlanItems } from '../../features/planBoard/api';
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters'; import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
@@ -45,7 +43,6 @@ import {
type AppConfig, type AppConfig,
type PlanCostTimeUnit, type PlanCostTimeUnit,
} from './appConfig'; } from './appConfig';
import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate';
import { import {
fetchWebPushConfig, fetchWebPushConfig,
registerPwaNotificationToken, registerPwaNotificationToken,
@@ -60,6 +57,7 @@ import {
getSavedPwaNotificationToken, getSavedPwaNotificationToken,
setSavedPwaNotificationToken, setSavedPwaNotificationToken,
} from './notificationIdentity'; } from './notificationIdentity';
import { resetNonAuthClientState } from './appMaintenance';
import { import {
ALLOWED_REGISTRATION_TOKEN, ALLOWED_REGISTRATION_TOKEN,
setRegisteredAccessToken, 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) { function formatDateTimeLabel(value: string | null) {
if (!value) { if (!value) {
return '-'; return '-';
@@ -490,29 +475,36 @@ function formatDateTimeLabel(value: string | null) {
} }
return new Intl.DateTimeFormat('ko-KR', { return new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}).format(parsed); }).format(parsed);
} }
function getWorkServerUpdateStatusLabel(item: ServerCommandItem | null) { function getServerVersionStatusClassName(item: ServerCommandItem | null) {
if (!item) { if (!item) {
return '확인 전'; return 'app-header__server-version-indicator--stale';
} }
if (item.buildRequired) { return item.buildRequired || item.updateAvailable
return '소스 변경 감지됨'; ? '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) { if (item.buildRequired || item.updateAvailable) {
return '새 빌드 대기 중'; return `${label} 최신 버전 아님`;
} }
if (item.availability === 'online') { return `${label} 최신 버전`;
return '최신 빌드 실행 중';
}
return '상태 확인 필요';
} }
function getClientNotificationPermission(): ClientNotificationPermissionState { function getClientNotificationPermission(): ClientNotificationPermissionState {
@@ -900,14 +892,6 @@ export function MainHeader({
); );
const [webPushConfigured, setWebPushConfigured] = useState(false); const [webPushConfigured, setWebPushConfigured] = useState(false);
const [isStandaloneMode, setIsStandaloneMode] = useState(false); const [isStandaloneMode, setIsStandaloneMode] = useState(false);
const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus>(() => getAppUpdateSnapshot().status);
const [appUpdateSupported, setAppUpdateSupported] = useState<boolean>(() => getAppUpdateSnapshot().supported);
const [appUpdateProgressPercent, setAppUpdateProgressPercent] = useState<number | null>(
() => getAppUpdateSnapshot().progressPercent,
);
const [appUpdateCurrentTaskLabel, setAppUpdateCurrentTaskLabel] = useState<string | null>(
() => getAppUpdateSnapshot().currentTaskLabel,
);
const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot()); const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot());
const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() => const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() =>
chatConnectionGateway.getSharedRuntimeSnapshot(), chatConnectionGateway.getSharedRuntimeSnapshot(),
@@ -921,14 +905,17 @@ export function MainHeader({
const [runtimeLogLoading, setRuntimeLogLoading] = useState(false); const [runtimeLogLoading, setRuntimeLogLoading] = useState(false);
const [runtimeLogError, setRuntimeLogError] = useState(''); const [runtimeLogError, setRuntimeLogError] = useState('');
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null); const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
const [appUpdateFeedback, setAppUpdateFeedback] = useState<InlineFeedback | null>(null); const [updateCheckFeedback, setUpdateCheckFeedback] = useState<InlineFeedback | null>(null);
const [appUpdateCopyFeedback, setAppUpdateCopyFeedback] = useState<InlineFeedback | null>(null); const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState<InlineFeedback | null>(null);
const previousAppUpdateStatusRef = useRef<AppUpdateStatus>(getAppUpdateSnapshot().status); const [clientResetting, setClientResetting] = useState(false);
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null); const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [workServerRestarting, setWorkServerRestarting] = useState(false); const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
const [workServerUpdateFeedback, setWorkServerUpdateFeedback] = useState<InlineFeedback | null>(null); const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [workServerUpdateCopyFeedback, setWorkServerUpdateCopyFeedback] = useState<InlineFeedback | null>(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess(); const { registeredToken, hasAccess } = useTokenAccess();
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig); const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig);
@@ -946,22 +933,17 @@ export function MainHeader({
const notificationStatusClassName = notificationEnabled const notificationStatusClassName = notificationEnabled
? 'app-header__status-dot--active' ? 'app-header__status-dot--active'
: 'app-header__status-dot--inactive'; : '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 = const chatConnectionStatusClassName =
chatConnection.connectionState === 'connected' chatConnection.connectionState === 'connected'
? 'app-header__status-dot--active' ? 'app-header__status-dot--active'
: chatConnection.connectionState === 'connecting' : chatConnection.connectionState === 'connecting'
? 'app-header__status-dot--progress' ? 'app-header__status-dot--progress'
: 'app-header__status-dot--inactive'; : 'app-header__status-dot--inactive';
const appPendingUpdateCount = appUpdateStatus === 'available' ? 1 : 0; const testServerPendingUpdateCount =
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
const workServerPendingUpdateCount = const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = appPendingUpdateCount + workServerPendingUpdateCount; const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
const settingsStatusClassName = const settingsStatusClassName =
totalPendingUpdateCount >= 2 totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive' ? 'app-header__status-dot--inactive'
@@ -1029,12 +1011,12 @@ export function MainHeader({
setRuntimeLogLoading(false); setRuntimeLogLoading(false);
} }
}; };
const canApplyAppUpdate = appUpdateSupported && appUpdateStatus === 'available'; const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey;
const canRefreshWorkServerStatus = hasAccess && !workServerRestarting && !workServerStatusLoading; const canResetClientState = !clientResetting;
const canApplyWorkServerUpdate = const canRestartServers =
hasAccess && hasAccess &&
!workServerRestarting && !workServerStatusLoading &&
!workServerStatusLoading; !serverRestartingKey;
const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat); const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat);
const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual( const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual(
appConfig.worklogAutomation, appConfig.worklogAutomation,
@@ -1194,32 +1176,6 @@ export function MainHeader({
}; };
}, [isRuntimeModalOpen]); }, [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(() => { useEffect(() => {
setTokenInput(registeredToken); setTokenInput(registeredToken);
}, [registeredToken]); }, [registeredToken]);
@@ -1248,11 +1204,12 @@ export function MainHeader({
useEffect(() => { useEffect(() => {
if (!hasAccess) { if (!hasAccess) {
setTestServerStatus(null);
setWorkServerStatus(null); setWorkServerStatus(null);
return; return;
} }
void refreshWorkServerStatus(true); void refreshUpdateTargets(true);
}, [hasAccess]); }, [hasAccess]);
useEffect(() => { useEffect(() => {
@@ -1260,7 +1217,7 @@ export function MainHeader({
return; return;
} }
void refreshWorkServerStatus(true); void refreshUpdateTargets(true);
}, [activeSettingsModal, hasAccess, settingsModalOpen]); }, [activeSettingsModal, hasAccess, settingsModalOpen]);
const ensureClientNotificationPermission = async () => { const ensureClientNotificationPermission = async () => {
@@ -1318,27 +1275,27 @@ export function MainHeader({
}; };
const syncNotificationEnabled = async (nextEnabled: boolean) => { const syncNotificationEnabled = async (nextEnabled: boolean) => {
if (notificationLoading) {
return;
}
const previousEnabled = notificationEnabled;
setNotificationCopyFeedback(null); setNotificationCopyFeedback(null);
setNotificationLoading(true);
setNotificationEnabled(nextEnabled);
if (nextEnabled) { if (nextEnabled) {
setNotificationFeedback({ tone: 'info', message: '알림 권한과 Web Push 등록 상태를 확인하는 중입니다.' });
const permissionGranted = await ensureClientNotificationPermission(); const permissionGranted = await ensureClientNotificationPermission();
if (!permissionGranted) { if (!permissionGranted) {
setNotificationEnabled(false); setNotificationEnabled(false);
setNotificationLoading(false);
return; return;
} }
} }
setNotificationLoading(true);
try { 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(); let registration = await getPushServiceWorkerRegistration();
if (!registration) { if (!registration) {
@@ -1407,6 +1364,13 @@ export function MainHeader({
} }
if (nextEnabled) { 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(); let subscription = await registration.pushManager.getSubscription();
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
@@ -1442,7 +1406,7 @@ export function MainHeader({
setNotificationEnabled(false); setNotificationEnabled(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' }); setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
} catch (error) { } catch (error) {
setNotificationEnabled(!nextEnabled); setNotificationEnabled(previousEnabled);
setNotificationFeedback({ setNotificationFeedback({
tone: 'error', tone: 'error',
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.', message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
@@ -1515,40 +1479,24 @@ export function MainHeader({
} }
}; };
const handleApplyAppUpdate = async () => { const refreshServerStatuses = async () => {
setAppUpdateCopyFeedback(null); const items = await fetchServerCommands();
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
if (!appUpdateSupported) { const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
setAppUpdateFeedback({ tone: 'warning', message: '현재 환경에서는 앱 업데이트를 지원하지 않습니다.' }); setTestServerStatus(nextTestServerStatus);
return; setWorkServerStatus(nextWorkServerStatus);
} return {
test: nextTestServerStatus,
if (appUpdateStatus === 'updating') { 'work-server': nextWorkServerStatus,
return; } satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
}
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 refreshWorkServerStatus = async (silent = false) => { const refreshUpdateTargets = async (silent = false) => {
if (!hasAccess) { if (!hasAccess) {
setTestServerStatus(null);
setWorkServerStatus(null); setWorkServerStatus(null);
if (!silent) { if (!silent) {
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' }); setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
} }
return null; return null;
} }
@@ -1556,20 +1504,18 @@ export function MainHeader({
setWorkServerStatusLoading(true); setWorkServerStatusLoading(true);
try { try {
const items = await fetchServerCommands(); const nextStatuses = await refreshServerStatuses();
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
setWorkServerStatus(nextWorkServerStatus);
if (!silent) { if (!silent) {
setWorkServerUpdateFeedback(null); setUpdateCheckFeedback(null);
} }
return nextWorkServerStatus; return nextStatuses;
} catch (error) { } catch (error) {
if (!silent) { if (!silent) {
setWorkServerUpdateFeedback({ setUpdateCheckFeedback({
tone: 'error', tone: 'error',
message: error instanceof Error ? error.message : '워크서버 업데이트 상태를 불러오지 못했습니다.', message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
}); });
} }
return null; return null;
@@ -1578,94 +1524,165 @@ export function MainHeader({
} }
}; };
const waitForWorkServerRestart = async () => { const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
for (let attempt = 0; attempt < 12; attempt += 1) { for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDuration(2500); await waitForDuration(2500);
try { try {
const nextStatus = await refreshWorkServerStatus(true); const nextStatuses = await refreshServerStatuses();
const nextStatus = nextStatuses[key];
if (!nextStatus) { if (!nextStatus) {
continue; continue;
} }
if (nextStatus.availability === 'online' && !nextStatus.updateAvailable && !nextStatus.buildRequired) { const restarted =
setWorkServerUpdateFeedback({ baseline == null ||
tone: 'success', nextStatus.startedAt !== baseline.startedAt ||
message: '워크서버가 재시작되었고 최신 빌드가 적용되었습니다.', nextStatus.checkedAt !== baseline.checkedAt;
});
return;
}
if (nextStatus.availability === 'online' && nextStatus.buildRequired) { if (nextStatus.availability === 'online' && restarted) {
setWorkServerUpdateFeedback({ return { ok: true, item: nextStatus };
tone: 'info',
message:
nextStatus.updateSummary ?? '워크서버는 재시작되었지만 최신 소스 기준으로 다시 빌드가 필요합니다.',
});
return;
} }
} catch { } catch {
// 서버시작 중이면 일시적으로 실패할 수 있어 다음 주기까지 기다립니다. // 서버 재기동 중에는 일시적으로 조회가 실패할 수 있니다.
} }
} }
setWorkServerUpdateFeedback({ return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
tone: 'info',
message: '워크서버 재시작 요청은 접수했습니다. 잠시 후 업데이트 상태를 다시 확인해 주세요.',
});
}; };
const handleApplyWorkServerUpdate = async () => { const handleResetClientState = async () => {
setWorkServerUpdateCopyFeedback(null); if (clientResetting) {
if (!hasAccess) {
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 적용은 권한 토큰 등록 후 사용할 수 있습니다.' });
return; return;
} }
if (workServerRestarting || workServerStatusLoading) { setClientResetCopyFeedback(null);
return; setClientResetFeedback(null);
} setClientResetting(true);
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);
try { try {
const result = await restartServerCommand('work-server'); const result = await resetNonAuthClientState();
setWorkServerStatus(result.item); const changedCount =
setWorkServerUpdateFeedback({ result.removedLocalStorageKeys.length +
result.removedSessionStorageKeys.length +
result.removedCacheKeys.length +
result.unregisteredServiceWorkerCount;
setClientResetFeedback({
tone: 'success', tone: 'success',
message: message:
result.restartState === 'accepted' changedCount > 0
? '워크서버 재시작 요청을 접수했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.' ? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.`
: '워크서버를 다시 시작했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.', : '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.',
}); });
await waitForWorkServerRestart(); window.setTimeout(() => {
window.location.replace(window.location.href);
}, 700);
} catch (error) { } catch (error) {
setWorkServerUpdateFeedback({ setClientResetFeedback({
tone: 'error', tone: 'error',
message: error instanceof Error ? error.message : '워크서버 재시작에 실패했습니다.', message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.',
}); });
} finally { } 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({
}} }}
> >
<span className="app-header__settings-icon"> <span className="app-header__settings-icon">
{appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />} <ReloadOutlined />
<span <span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
className={`app-header__status-dot ${appUpdateStatusClassName}`}
aria-hidden="true"
/>
</span> </span>
<span className="app-header__settings-label"></span> <span className="app-header__settings-label"></span>
</button> </button>
@@ -2724,9 +2738,6 @@ export function MainHeader({
} }
onChange={(value) => { onChange={(value) => {
onChangeTopMenu(value as 'docs' | 'plans'); onChangeTopMenu(value as 'docs' | 'plans');
if (isMobileViewport && sidebarCollapsed) {
onToggleSidebar();
}
}} }}
/> />
</Space> </Space>
@@ -3116,110 +3127,111 @@ export function MainHeader({
) : null} ) : null}
{activeSettingsModal === 'update' ? ( {activeSettingsModal === 'update' ? (
<Space direction="vertical" size={6} style={{ width: '100%' }}> <Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text> <Text strong> </Text>
<Text type="secondary"> : {appUpdateSupported ? getAppUpdateStatusLabel(appUpdateStatus) : '미지원'}</Text> <Text type="secondary">
{import.meta.env.DEV ? (
<Text type="secondary"> <span
. className={`app-header__server-version-indicator ${getServerVersionStatusClassName(testServerStatus)}`}
</Text> aria-label={getServerVersionStatusTitle(testServerStatus, '테스트')}
) : null} title={getServerVersionStatusTitle(testServerStatus, '테스트')}
{renderFeedback(appUpdateFeedback, appUpdateCopyFeedback, setAppUpdateCopyFeedback)} style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
{appUpdateStatus === 'updating' ? ( aria-hidden="true"
<div className="app-header__update-progress" role="status" aria-live="polite"> />
<div className="app-header__update-progress-copy"> </Text>
<Text strong> </Text> <Text type="secondary">
<Text type="secondary"> : {getServerLastSourceChangedDateLabel(testServerStatus)}
{appUpdateCurrentTaskLabel ?? '새 버전을 내려받고 적용하는 중입니다. 잠시만 기다려 주세요.'} </Text>
</Text> <Text type="secondary">
</div>
<div className="app-header__update-progress-task"> <span
<Text type="secondary"> </Text> className={`app-header__server-version-indicator ${getServerVersionStatusClassName(workServerStatus)}`}
<Text>{appUpdateCurrentTaskLabel ?? '새 버전 반영 준비'}</Text> aria-label={getServerVersionStatusTitle(workServerStatus, '워크')}
</div> title={getServerVersionStatusTitle(workServerStatus, '워크')}
<Progress style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
percent={appUpdateProgressPercent ?? 0} aria-hidden="true"
size="small" />
showInfo={false} </Text>
status="active" <Text type="secondary">
strokeColor="#2563eb" : {getServerLastSourceChangedDateLabel(workServerStatus)}
trailColor="rgba(37, 99, 235, 0.12)" </Text>
/> {renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
</div>
) : null}
<Button <Button
block block
icon={appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />} icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
disabled={!canApplyAppUpdate && appUpdateStatus !== 'updating'} loading={workServerStatusLoading}
disabled={!canRefreshWorkServerStatus}
onClick={() => { onClick={() => {
void handleApplyAppUpdate(); void refreshUpdateTargets();
}} }}
> >
{appUpdateStatus === 'updating'
? '업데이트 진행 중'
: appUpdateStatus === 'ready'
? '적용할 앱 업데이트 없음'
: import.meta.env.DEV
? '개발 변경 반영'
: '앱 업데이트 적용'}
</Button> </Button>
<Text strong style={{ marginTop: 8 }}> <Text strong style={{ marginTop: 8 }}>
Work /
</Text> </Text>
<Text type="secondary"> <Text type="secondary">
: {getWorkServerUpdateStatusLabel(workServerStatus)} , , / .
</Text>
{renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)}
<Button
block
danger
icon={clientResetting ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={clientResetting}
disabled={!canResetClientState}
onClick={() => {
void handleResetClientState();
}}
>
{clientResetting ? '초기화 진행 중' : '초기화'}
</Button>
<Text strong style={{ marginTop: 8 }}>
</Text> </Text>
<Text type="secondary"> <Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.runningBuiltAt ?? null)} : {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
</Text> </Text>
<Text type="secondary"> <Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.latestBuiltAt ?? null)} : {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
</Text> </Text>
<Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.latestSourceChangeAt ?? null)}
</Text>
{workServerStatus?.latestSourceChangePath ? (
<Text type="secondary"> : {workServerStatus.latestSourceChangePath}</Text>
) : null}
{!hasAccess ? ( {!hasAccess ? (
<Text type="warning"> .</Text> <Text type="warning"> .</Text>
) : null} ) : null}
{workServerStatus?.updateSummary ? <Text type="secondary">{workServerStatus.updateSummary}</Text> : null} {renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
{renderFeedback(
workServerUpdateFeedback,
workServerUpdateCopyFeedback,
setWorkServerUpdateCopyFeedback,
)}
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}> <Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
<Button <Button
block={screens.xs} block={screens.xs}
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />} icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={workServerStatusLoading} loading={serverRestartingKey === 'test'}
disabled={!canRestartServers}
onClick={() => { onClick={() => {
void refreshWorkServerStatus(); void handleRestartSingleServer('test');
}} }}
disabled={!canRefreshWorkServerStatus}
> >
</Button>
<Button
block={screens.xs}
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'work-server'}
disabled={!canRestartServers}
onClick={() => {
void handleRestartSingleServer('work-server');
}}
>
</Button> </Button>
<Button <Button
type="primary" type="primary"
block={screens.xs} block={screens.xs}
icon={workServerRestarting ? <ReloadOutlined spin /> : <DownloadOutlined />} icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={workServerRestarting} loading={serverRestartingKey === 'all'}
disabled={!canRestartServers}
onClick={() => { onClick={() => {
void handleApplyWorkServerUpdate(); void handleRestartBothServers();
}} }}
disabled={!canApplyWorkServerUpdate}
> >
{workServerRestarting
? '워크서버 재시작 중'
: workServerStatusLoading
? '상태 확인 중'
: workServerStatus == null
? 'Work 서버 상태 확인 후 적용'
: workServerStatus.updateAvailable || workServerStatus.buildRequired
? 'Work 서버 업데이트 적용'
: '적용할 Work 서버 업데이트 없음'}
</Button> </Button>
</Space> </Space>
</Space> </Space>

View File

@@ -214,6 +214,24 @@
background: #2563eb; 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 { .app-header__settings-label {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -344,6 +362,15 @@
background: rgba(255, 255, 255, 0.98); 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 { .app-sider__inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -680,6 +707,11 @@
height: calc(100vh - 52px); height: calc(100vh - 52px);
} }
.app-sider--mobile-inline.ant-layout-sider {
position: static;
height: auto;
}
.app-main-content.ant-layout-content { .app-main-content.ant-layout-content {
padding: 0; padding: 0;
min-height: calc(100dvh - 52px); min-height: calc(100dvh - 52px);

View File

@@ -1,4 +1,5 @@
import { Layout, Menu, Space, Tag, Typography } from 'antd'; import { Layout, Menu, Space, Tag, Typography } from 'antd';
import type { ItemType } from 'antd/es/menu/interface';
import type { MainSidebarProps } from './types'; import type { MainSidebarProps } from './types';
const { Sider } = Layout; const { Sider } = Layout;
@@ -9,6 +10,7 @@ export function MainSidebar({
hasAccess, hasAccess,
sidebarCollapsed, sidebarCollapsed,
isMobileViewport, isMobileViewport,
mobileInline = false,
openKeys: controlledOpenKeys, openKeys: controlledOpenKeys,
apiMenuItems, apiMenuItems,
docsMenuItems, docsMenuItems,
@@ -30,19 +32,46 @@ export function MainSidebar({
onSelectChatMenu, onSelectChatMenu,
onSelectPlayMenu, onSelectPlayMenu,
}: MainSidebarProps) { }: 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 effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu;
const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis'; const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis';
const visibleOpenKeys = sidebarCollapsed const visibleOpenKeys = sidebarCollapsed ? [] : controlledOpenKeys;
? []
: 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 selectedKeys = const selectedKeys =
effectiveTopMenu === 'docs' effectiveTopMenu === 'docs'
? [selectedDocsMenu] ? [selectedDocsMenu]
@@ -61,13 +90,28 @@ export function MainSidebar({
: effectiveTopMenu === 'play' : effectiveTopMenu === 'play'
? [...(playMenuItems ?? [])] ? [...(playMenuItems ?? [])]
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])]; : [...(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 ( return (
<Sider <Sider
width={isMobileViewport ? '100%' : 260} width={isMobileViewport ? '100%' : 260}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
collapsedWidth={isMobileViewport ? 0 : 72} collapsedWidth={isMobileOverlay ? 0 : 72}
className={isMobileViewport ? 'app-sider app-sider--mobile' : 'app-sider'} className={
isMobileOverlay ? 'app-sider app-sider--mobile' : mobileInline ? 'app-sider app-sider--mobile-inline' : 'app-sider'
}
theme="light" theme="light"
> >
<div className="app-sider__inner"> <div className="app-sider__inner">
@@ -80,37 +124,30 @@ export function MainSidebar({
<Menu <Menu
mode="inline" mode="inline"
triggerSubMenuAction={isMobileViewport ? 'click' : 'hover'}
inlineCollapsed={sidebarCollapsed} inlineCollapsed={sidebarCollapsed}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
openKeys={visibleOpenKeys} openKeys={visibleOpenKeys}
items={sidebarItems} items={sidebarItems}
onOpenChange={(keys) => { onOpenChange={(keys) => {
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<string>();
onOpenKeysChange(nextKeys.filter((key) => key === activeRootKey || allowedChildKeys.has(key)));
}} }}
onClick={({ key, keyPath }) => { onClick={({ key, keyPath }) => {
if (keyPath.includes('docs-group')) { handleMenuRouteSelection(String(key), keyPath as string[]);
onSelectDocsMenu(key); }}
return; onSelect={({ key, keyPath }) => {
} handleMenuRouteSelection(String(key), keyPath as string[]);
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']);
}
}} }}
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react';
import { appendClientIdHeader } from './clientIdentity'; import { appendClientIdHeader } from './clientIdentity';
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity'; 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_EVENT = 'work-server:app-config';
const APP_CONFIG_API_PATH = '/app-config'; const APP_CONFIG_API_PATH = '/app-config';
const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation'; const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';

View File

@@ -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<string> = 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;
}

View File

@@ -1,5 +1,3 @@
import { registerSW } from 'virtual:pwa-register';
export type AppUpdateStatus = 'idle' | 'available' | 'updating' | 'ready'; export type AppUpdateStatus = 'idle' | 'available' | 'updating' | 'ready';
export type AppUpdateSnapshot = { export type AppUpdateSnapshot = {
@@ -342,27 +340,42 @@ export function initializeAppUpdate() {
return; return;
} }
serviceWorkerUpdater = registerSW({ emit();
immediate: true,
onNeedRefresh() { void import('virtual:pwa-register')
snapshot = { .then(({ registerSW }) => {
supported: true, serviceWorkerUpdater = registerSW({
status: 'available', immediate: true,
progressPercent: null, onNeedRefresh() {
currentTaskLabel: null, snapshot = {
}; supported: true,
emit(); status: 'available',
}, progressPercent: null,
onRegistered() { currentTaskLabel: null,
snapshot = { };
supported: true, emit();
status: 'ready', },
progressPercent: null, onRegistered() {
currentTaskLabel: null, snapshot = {
}; supported: true,
emit(); status: 'ready',
}, progressPercent: null,
onRegisterError() { currentTaskLabel: null,
};
emit();
},
onRegisterError() {
snapshot = {
supported: isAppUpdateSupported(),
status: isAppUpdateSupported() ? 'ready' : 'idle',
progressPercent: null,
currentTaskLabel: null,
};
emit();
},
});
})
.catch(() => {
snapshot = { snapshot = {
supported: isAppUpdateSupported(), supported: isAppUpdateSupported(),
status: isAppUpdateSupported() ? 'ready' : 'idle', status: isAppUpdateSupported() ? 'ready' : 'idle',
@@ -370,10 +383,7 @@ export function initializeAppUpdate() {
currentTaskLabel: null, currentTaskLabel: null,
}; };
emit(); emit();
}, });
});
emit();
} }
export function subscribeAppUpdate(listener: Listener) { export function subscribeAppUpdate(listener: Listener) {

View File

@@ -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'; export type ChatPermissionRole = 'guest' | 'token-user';
@@ -21,8 +22,12 @@ export type ChatTypeInput = {
enabled?: boolean; 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_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<ChatPermissionRole, string> = { export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
guest: '게스트', guest: '게스트',
@@ -33,11 +38,12 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{ {
id: 'general-request', id: 'general-request',
name: '일반 요청', name: '일반 요청',
description: '일반 Codex Live 요청입니다. 현재 로컬 main 작업본 기준으로 바로 확인하고 필요 시 소스를 수정합니다.', description:
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
isTemplate: false, isTemplate: false,
permissions: ['token-user'], permissions: ['token-user'],
enabled: true, enabled: true,
updatedAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-21T00:00:00.000Z',
}, },
{ {
id: 'api-request-template', id: 'api-request-template',
@@ -54,6 +60,10 @@ function normalizeText(value: string | null | undefined) {
return value?.trim() ?? ''; 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[] { function normalizePermissions(permissions: ChatPermissionRole[] | null | undefined): ChatPermissionRole[] {
const nextPermissions = Array.from( const nextPermissions = Array.from(
new Set( new Set(
@@ -87,75 +97,216 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
}; };
} }
function ensureDefaultChatTypes(chatTypes: ChatTypeRecord[]) { function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
const defaultsById = new Map(DEFAULT_CHAT_TYPES.map((item) => [item.id, item])); return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
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'));
} }
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<string, ChatTypeRecord>();
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<ChatTypeRecord>[]) {
return dedupeChatTypes(
chatTypes
.map((item) => normalizeChatType(item))
.filter((item): item is ChatTypeRecord => Boolean(item)),
);
}
function emitChatTypesChange() {
if (typeof window === 'undefined') { 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<T>(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 { 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<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestChatTypes<T>(init?: RequestInit) {
try {
return await requestChatTypesOnce<T>(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<T>(CHAT_TYPES_FALLBACK_BASE_URL, init);
}
}
function readLegacyDeletedChatTypeIds() {
if (typeof window === 'undefined') {
return new Set<string>();
}
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<string>();
}
}
function readLegacyChatTypes() {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
if (!raw) { if (!raw) {
return DEFAULT_CHAT_TYPES; return null;
} }
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[]; const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
return DEFAULT_CHAT_TYPES; return null;
} }
const normalized = parsed const deletedIds = readLegacyDeletedChatTypeIds();
.map((item) => normalizeChatType(item)) const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
.filter((item): item is ChatTypeRecord => Boolean(item)); return normalized;
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;
} catch { } catch {
return DEFAULT_CHAT_TYPES; return null;
} }
} }
export function saveChatTypes(chatTypes: ChatTypeRecord[]) { function clearLegacyChatTypeStorage() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(chatTypes)); window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
window.dispatchEvent(new CustomEvent(CHAT_TYPE_SYNC_EVENT)); 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<ChatTypeRecord>[] | 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<ChatTypeRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ chatTypes: resolved }),
});
emitChatTypesChange();
clearLegacyChatTypeStorage();
return sanitizeChatTypes(response.chatTypes);
} }
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) { export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
@@ -170,13 +321,25 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
}); });
if (!nextRecord) { 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.push(nextRecord);
nextChatTypes.sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); return sanitizeChatTypes(nextChatTypes);
return 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[] { export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
@@ -188,31 +351,68 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
} }
export function useChatTypeRegistry() { export function useChatTypeRegistry() {
const [chatTypes, setChatTypes] = useState<ChatTypeRecord[]>(() => loadChatTypes()); const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { isMountedRef.current = true;
return undefined;
}
const syncChatTypes = () => { const syncChatTypes = async () => {
setChatTypes(loadChatTypes()); 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); void syncChatTypes();
window.addEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
const handleSync = () => {
void syncChatTypes();
};
window.addEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
return () => { return () => {
window.removeEventListener('storage', syncChatTypes); isMountedRef.current = false;
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes); window.removeEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
}; };
}, []); }, []);
return { return {
chatTypes, chatTypes,
setChatTypes: (nextChatTypes: ChatTypeRecord[]) => { isLoading,
saveChatTypes(nextChatTypes); errorMessage,
setChatTypes(nextChatTypes); setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
const resolved = await saveChatTypesToServer(nextChatTypes);
if (isMountedRef.current) {
setChatTypesState(resolved);
setErrorMessage('');
}
return resolved;
}, },
}; };
} }

View File

@@ -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'; import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
const { Text } = Typography; const { Text } = Typography;
@@ -12,6 +33,460 @@ type ConversationRoomPaneProps = {
errorMessage: string; 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<string>();
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(
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer">
{label.trim() || href}
</a>,
);
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 (
<div key={`img-${index}`} className="app-chat-message__block app-chat-message__block--image">
<InlineImage
src={src}
alt={alt.trim() || 'chat image'}
className="app-chat-message__inline-image markdown-preview__image"
fallbackText="이미지 preview를 불러오지 못했습니다."
/>
</div>
);
}
if (!line.length) {
return <div key={`space-${index}`} className="app-chat-message__block app-chat-message__block--spacer" aria-hidden="true" />;
}
return (
<div key={`line-${index}`} className="app-chat-message__block">
{renderMessageInlineParts(line)}
</div>
);
});
}
function extractMessageRenderPayload(text: string): MessageRenderPayload {
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = text
.replace(DIFF_CODE_BLOCK_PATTERN, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
return { visibleText, diffBlocks };
}
function isLikelyCollapsibleMessage(text: string) {
const normalizedText = String(text ?? '').trim();
if (!normalizedText) {
return false;
}
if (normalizedText.length > COLLAPSIBLE_MESSAGE_CHAR_COUNT) {
return true;
}
return normalizedText
.split('\n')
.map((line) => line.trim())
.filter(Boolean).length > COLLAPSIBLE_MESSAGE_LINE_COUNT;
}
async function createPreviewFetchError(response: Response) {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = '';
try {
responseMessage = contentType.includes('application/json')
? String(((await response.json()) as { message?: string }).message ?? '').trim()
: (await response.text()).trim();
} catch {
responseMessage = '';
}
const statusLabel =
response.status === 403
? '이 문서는 현재 권한으로 열 수 없습니다.'
: response.status === 404
? '이 문서를 찾을 수 없습니다.'
: response.status === 401
? '이 문서를 열기 위한 인증이 필요합니다.'
: `preview 요청이 실패했습니다. (${response.status})`;
return new Error(responseMessage ? `${statusLabel} ${responseMessage}` : statusLabel);
}
function InlineMessagePreview({
target,
isExpanded,
onToggle,
}: {
target: ChatPreviewTarget;
isExpanded: boolean;
onToggle: () => void;
}) {
const [previewText, setPreviewText] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState('');
useEffect(() => {
if (!isExpanded || target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
return;
}
const controller = new AbortController();
setIsPreviewLoading(true);
setPreviewError('');
setPreviewContentType('');
fetch(target.url, { cache: 'no-store', signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
throw await createPreviewFetchError(response);
}
setPreviewContentType(response.headers.get('content-type') ?? '');
setPreviewText((await response.text()).slice(0, 1600));
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}
setPreviewText('');
setPreviewContentType('');
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
})
.finally(() => {
if (!controller.signal.aborted) {
setIsPreviewLoading(false);
}
});
return () => {
controller.abort();
};
}, [isExpanded, target.kind, target.url]);
return (
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
{resolveChatPreviewGlyph(target.kind)}
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.label}</span>
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<CopyOutlined />}
aria-label="preview 내용 복사"
onClick={() => {
void copyPreviewContent({
kind: target.kind,
url: target.url,
fallbackText: previewText,
})
.then((result) => {
if (result === 'image') {
antdMessage.success('preview 이미지를 복사했습니다.');
return;
}
if (result === 'url') {
antdMessage.success('preview 이미지 URL을 복사했습니다.');
return;
}
antdMessage.success('preview 내용을 복사했습니다.');
})
.catch((error: unknown) =>
antdMessage.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.'),
);
}}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
aria-label={isExpanded ? 'preview 최대화 해제' : 'preview 최대화'}
onClick={onToggle}
/>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
onClick={onToggle}
/>
</div>
</div>
{isExpanded ? (
<div className="app-chat-preview-card__body">
<ChatPreviewBody
target={target}
previewText={previewText}
isPreviewLoading={isPreviewLoading}
previewError={previewError}
previewContentType={previewContentType}
/>
</div>
) : null}
</section>
);
}
function DiffMessagePreview({
diffText,
fileCount,
isExpanded,
isFullscreen,
onToggle,
onToggleFullscreen,
}: {
diffText: string;
fileCount: number;
isExpanded: boolean;
isFullscreen: boolean;
onToggle: () => void;
onToggleFullscreen: () => void;
}) {
return (
<section
className={`app-chat-preview-card${isExpanded || isFullscreen ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}${
isFullscreen ? ' app-chat-preview-card--fullscreen' : ''
}`}
>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<CodeOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">Codex Diff</span>
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}`}</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<CopyOutlined />}
aria-label="diff 복사"
onClick={() => {
void copyText(diffText)
.then(() => antdMessage.success('diff를 복사했습니다.'))
.catch((error: unknown) => antdMessage.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.'));
}}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
aria-label={isFullscreen ? 'diff 최대화 해제' : 'diff 최대화'}
onClick={onToggleFullscreen}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<DownloadOutlined />}
aria-label="diff 다운로드"
onClick={() => {
downloadTextFile(diffText, 'codex-result.diff', 'text/x-diff;charset=utf-8');
}}
/>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded || isFullscreen ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded || isFullscreen ? 'diff 접기' : 'diff 펼치기'}
onClick={onToggle}
/>
</div>
</div>
{isExpanded || isFullscreen ? (
<div className="app-chat-preview-card__body">
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
<CodexDiffBlock
diffText={diffText}
showToolbar={false}
expandAll={isFullscreen}
summary={`파일 ${fileCount}개 diff preview`}
/>
</div>
</div>
) : null}
</section>
);
}
export function ConversationRoomPane({ export function ConversationRoomPane({
sessionId, sessionId,
messages, messages,
@@ -20,6 +495,30 @@ export function ConversationRoomPane({
loadingLabel, loadingLabel,
errorMessage, errorMessage,
}: ConversationRoomPaneProps) { }: ConversationRoomPaneProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
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]);
if (!sessionId) { if (!sessionId) {
return ( return (
<section className="chat-v2__pane chat-v2__pane--room"> <section className="chat-v2__pane chat-v2__pane--room">
@@ -69,15 +568,122 @@ export function ConversationRoomPane({
<Empty description="메시지가 없습니다." /> <Empty description="메시지가 없습니다." />
</div> </div>
) : ( ) : (
messages.map((message) => ( messages.map((message) => {
<article key={`${message.id}-${message.timestamp}`} className={`chat-v2__message chat-v2__message--${message.author}`}> const canCollapseMessage = isLikelyCollapsibleMessage(message.text);
<div className="chat-v2__message-meta"> const isExpandedMessage = expandedMessageIds.includes(message.id);
<Text strong>{message.author}</Text> const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
<Text type="secondary">{message.timestamp}</Text> 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 (
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'}</strong>
<span>{formatChatTimestamp(message.timestamp)}</span>
</div>
{message.author !== 'system' ? (
<Button
type="text"
size="small"
className="app-chat-message__header-action"
icon={<CopyOutlined />}
aria-label="메시지 복사"
onClick={() => {
void copyText(message.text)
.then(() => antdMessage.success('메시지를 복사했습니다.'))
.catch((error: unknown) =>
antdMessage.error(error instanceof Error ? error.message : '메시지를 복사하지 못했습니다.'),
);
}}
/>
) : null}
</div>
<div className={messageBodyClassName}>{visibleText ? renderMessageBody(visibleText) : null}</div>
{canCollapseMessage ? (
<Button
type="text"
size="small"
className="app-chat-message__expand"
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
onClick={() => {
setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}}
>
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null}
</article>
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{diffBlocks.map((diffText, index) => {
const previewKey = `${message.id}-diff-${index}`;
return (
<DiffMessagePreview
key={previewKey}
diffText={diffText}
fileCount={Math.max(1, Array.from(diffText.matchAll(/^diff --git /gm)).length)}
isExpanded={expandedPreviewKey === previewKey}
isFullscreen={fullscreenPreviewKey === previewKey}
onToggle={() => {
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 (
<InlineMessagePreview
key={previewKey}
target={target}
isExpanded={expandedPreviewKey === previewKey}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
}}
/>
);
})}
</div>
) : null}
</div> </div>
<div className="chat-v2__message-body">{message.text}</div> );
</article> })
))
)} )}
</div> </div>
</section> </section>

View File

@@ -35,6 +35,7 @@ export type ChatGateway = {
createConversation: (args: { createConversation: (args: {
sessionId: string; sessionId: string;
title: string; title: string;
chatTypeId?: string | null;
contextLabel?: string; contextLabel?: string;
contextDescription?: string; contextDescription?: string;
notifyOffline?: boolean; notifyOffline?: boolean;
@@ -43,7 +44,7 @@ export type ChatGateway = {
updateConversation: ( updateConversation: (
sessionId: string, sessionId: string,
payload: Partial< payload: Partial<
Pick<ChatConversationSummary, 'title' | 'notifyOffline' | 'hasUnreadResponse'> Pick<ChatConversationSummary, 'title' | 'chatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'>
>, >,
) => Promise<ChatConversationSummary>; ) => Promise<ChatConversationSummary>;
deleteConversation: (sessionId: string) => Promise<void>; deleteConversation: (sessionId: string) => Promise<void>;

View File

@@ -1,9 +1,6 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types'; import type { ChatConversationSummary } from '../../mainChatPanel/types';
import {
CHAT_CONVERSATIONS_UPDATED_EVENT,
readChatConversationsUpdatedEvent,
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = { type UseConversationListDataOptions = {
@@ -19,6 +16,33 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>; setConversationSearch: Dispatch<SetStateAction<string>>;
}; };
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({ export function useConversationListData({
requestedSessionId, requestedSessionId,
}: UseConversationListDataOptions): UseConversationListDataResult { }: UseConversationListDataOptions): UseConversationListDataResult {
@@ -31,9 +55,11 @@ export function useConversationListData({
try { try {
const items = await chatGateway.listConversations(); const items = await chatGateway.listConversations();
setConversationItems(items); setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch { } catch {
setConversationItems([]); setConversationItems((previous) => previous);
} finally { } finally {
setIsConversationListLoading(false); setIsConversationListLoading(false);
} }
@@ -46,12 +72,14 @@ export function useConversationListData({
.listConversations() .listConversations()
.then((items) => { .then((items) => {
if (!isCancelled) { if (!isCancelled) {
setConversationItems(items); setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} }
}) })
.catch(() => { .catch(() => {
if (!isCancelled) { if (!isCancelled) {
setConversationItems([]); setConversationItems((previous) => previous);
} }
}) })
.finally(() => { .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 { return {
conversationItems, conversationItems,
setConversationItems, setConversationItems,

View File

@@ -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 { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
import type { import type {
ChatConversationRequest, ChatConversationRequest,
@@ -7,39 +8,28 @@ import type {
ChatMessage, ChatMessage,
} from '../../mainChatPanel/types'; } from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3; const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20; const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) { function mergeConversationRequests(
const normalizedSessionId = sessionId.trim(); previous: ChatConversationRequest[],
incoming: ChatConversationRequest[],
if (!normalizedSessionId) {
return [] as ChatMessage[];
}
return cache.get(normalizedSessionId) ?? [];
}
function getBestAvailableSessionMessages(
cache: Map<string, ChatMessage[]>,
sessionId: string, 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 [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
return cachedMessages;
}
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
} }
type UseConversationRoomDataOptions = { type UseConversationRoomDataOptions = {
activeSessionId: string; activeSessionId: string;
oldestLoadedMessageId: number | null;
reloadKey: number;
connectionState: 'connecting' | 'connected' | 'disconnected'; connectionState: 'connecting' | 'connected' | 'disconnected';
shouldBlockConversationWhileLoading: (sessionId: string) => boolean; captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
captureViewportRestoreSnapshot: () => void;
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>; sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
messagesRef: MutableRefObject<ChatMessage[]>; messagesRef: MutableRefObject<ChatMessage[]>;
pendingViewportRestoreRef: MutableRefObject<boolean>; pendingViewportRestoreRef: MutableRefObject<boolean>;
@@ -59,8 +49,9 @@ type UseConversationRoomDataOptions = {
export function useConversationRoomData({ export function useConversationRoomData({
activeSessionId, activeSessionId,
oldestLoadedMessageId,
reloadKey,
connectionState, connectionState,
shouldBlockConversationWhileLoading,
captureViewportRestoreSnapshot, captureViewportRestoreSnapshot,
sessionMessageCacheRef, sessionMessageCacheRef,
messagesRef, messagesRef,
@@ -78,8 +69,11 @@ export function useConversationRoomData({
queueViewportPrependRestore, queueViewportPrependRestore,
viewportRef, viewportRef,
}: UseConversationRoomDataOptions) { }: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef('');
useEffect(() => { useEffect(() => {
if (!activeSessionId.trim()) { if (!activeSessionId.trim()) {
previousSessionIdRef.current = '';
setMessages([]); setMessages([]);
setRequestItems([]); setRequestItems([]);
setIsConversationContentLoading(false); setIsConversationContentLoading(false);
@@ -92,53 +86,95 @@ export function useConversationRoomData({
let isCancelled = false; let isCancelled = false;
const requestedSessionId = activeSessionId; const requestedSessionId = activeSessionId;
const waitForRetry = (delayMs: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
const loadConversationDetail = async () => { const loadConversationDetail = async () => {
captureViewportRestoreSnapshot(); const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
previousSessionIdRef.current = requestedSessionId;
captureViewportRestoreSnapshot({
forceStickToBottom: isSessionChanged,
});
pendingViewportRestoreRef.current = true; pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.'); setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId)); const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
if (cachedMessages.length > 0) {
setMessages(cachedMessages);
}
setIsConversationContentLoading(true);
setIsDeferringAuxiliaryChatRequests(true); setIsDeferringAuxiliaryChatRequests(true);
try { try {
const response = await chatGateway.getConversationDetail(requestedSessionId, { let response: Awaited<ReturnType<typeof chatGateway.getConversationDetail>> | null = null;
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT, 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) { if (!isCancelled && response.item.sessionId === requestedSessionId) {
setConversationItems((previous) => { setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId); const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) { 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( const baseMessages =
sessionMessageCacheRef.current, isSessionChanged
requestedSessionId, ? []
activeSessionId, : requestedSessionId === activeSessionId
messagesRef.current, ? messagesRef.current
); : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages); const mergedMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages); sessionMessageCacheRef.current.set(requestedSessionId, mergedMessages);
setRequestItems(response.requests); setMessages(mergedMessages);
setRequestItems((previous) => {
const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId);
return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)];
});
setHasOlderMessages(response.hasOlderMessages); setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId); setOldestLoadedMessageId(response.oldestLoadedMessageId);
} }
} catch { } catch {
if (!isCancelled) { if (!isCancelled) {
const cachedMessages = getBestAvailableSessionMessages( setMessages(cachedMessages);
sessionMessageCacheRef.current, setHasOlderMessages(false);
requestedSessionId, setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
activeSessionId, setConversationLoadingLabel(
messagesRef.current, cachedMessages.length > 0
? '저장된 대화 내용을 먼저 보여주고 있습니다. 서버 연결을 다시 확인해 주세요.'
: '대화 내용을 다시 불러오지 못했습니다.',
); );
if (cachedMessages.length > 0) {
setMessages(cachedMessages);
}
} }
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
@@ -158,6 +194,7 @@ export function useConversationRoomData({
captureViewportRestoreSnapshot, captureViewportRestoreSnapshot,
messagesRef, messagesRef,
pendingViewportRestoreRef, pendingViewportRestoreRef,
reloadKey,
sessionMessageCacheRef, sessionMessageCacheRef,
setConversationItems, setConversationItems,
setConversationLoadingLabel, setConversationLoadingLabel,
@@ -167,105 +204,17 @@ export function useConversationRoomData({
setMessages, setMessages,
setOldestLoadedMessageId, setOldestLoadedMessageId,
setRequestItems, setRequestItems,
shouldBlockConversationWhileLoading,
]); ]);
useEffect(() => { useEffect(() => {
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) { if (connectionState === 'connected') {
return; shouldRestoreConversationAfterReconnectRef.current = false;
} }
}, [connectionState, shouldRestoreConversationAfterReconnectRef]);
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,
]);
const loadOlderMessages = async () => { const loadOlderMessages = async () => {
const requestedSessionId = activeSessionId.trim(); const requestedSessionId = activeSessionId.trim();
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null; const oldestVisibleMessageId = oldestLoadedMessageId;
if (!requestedSessionId || oldestVisibleMessageId == null) { if (!requestedSessionId || oldestVisibleMessageId == null) {
return; return;
@@ -275,11 +224,14 @@ export function useConversationRoomData({
try { try {
const response = await chatGateway.getConversationDetail(requestedSessionId, { const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE, limit: OLDER_CONVERSATION_REQUEST_PAGE_SIZE,
beforeMessageId: oldestVisibleMessageId, 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); setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId); setOldestLoadedMessageId(response.oldestLoadedMessageId);
return; return;
@@ -293,7 +245,10 @@ export function useConversationRoomData({
queueViewportPrependRestore(previousScrollHeight, previousScrollTop); queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages); sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(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); setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId); setOldestLoadedMessageId(response.oldestLoadedMessageId);
} finally { } finally {

View File

@@ -15,7 +15,6 @@ type UseConversationViewControllerOptions = {
previewItems: PreviewItem[]; previewItems: PreviewItem[];
selectedChatTypeId: string | null; selectedChatTypeId: string | null;
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null }; composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
setActiveSystemStatus: (value: string | null) => void; setActiveSystemStatus: (value: string | null) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>; setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
setCopiedMessageId: (value: number | null) => void; setCopiedMessageId: (value: number | null) => void;
@@ -31,7 +30,6 @@ export function useConversationViewController({
composerRef, composerRef,
previewItems, previewItems,
selectedChatTypeId, selectedChatTypeId,
sessionMessageCacheRef,
setActiveSystemStatus, setActiveSystemStatus,
setComposerAttachments, setComposerAttachments,
setCopiedMessageId, setCopiedMessageId,
@@ -59,7 +57,7 @@ export function useConversationViewController({
previousSessionIdRef.current = activeSessionId; previousSessionIdRef.current = activeSessionId;
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []); setMessages([]);
setDraft(''); setDraft('');
setComposerAttachments([]); setComposerAttachments([]);
setCopiedMessageId(null); setCopiedMessageId(null);
@@ -70,7 +68,6 @@ export function useConversationViewController({
setIsResourceStripOpen(false); setIsResourceStripOpen(false);
}, [ }, [
activeSessionId, activeSessionId,
sessionMessageCacheRef,
setActiveSystemStatus, setActiveSystemStatus,
setComposerAttachments, setComposerAttachments,
setCopiedMessageId, setCopiedMessageId,

View File

@@ -132,7 +132,16 @@ export function useConversationViewportController({
setShowScrollToBottom(!isNearBottom); setShowScrollToBottom(!isNearBottom);
}, [viewportRef]); }, [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; const viewport = viewportRef.current;
if (!viewport) { if (!viewport) {

View File

@@ -6,6 +6,7 @@ import {
fetchNotificationMessages, fetchNotificationMessages,
updateNotificationMessageReadState, updateNotificationMessageReadState,
type NotificationMessageItem, type NotificationMessageItem,
type NotificationMessageListStatus,
} from '../../notificationApi'; } from '../../notificationApi';
import { useUnreadCounts } from './useUnreadCounts'; import { useUnreadCounts } from './useUnreadCounts';
@@ -20,6 +21,7 @@ function mergeMessageItem(items: NotificationMessageItem[], nextItem: Notificati
} }
export function useNotificationCenterData(drawerOpen: boolean) { export function useNotificationCenterData(drawerOpen: boolean) {
const [listStatus, setListStatus] = useState<NotificationMessageListStatus>('unread');
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [messages, setMessages] = useState<NotificationMessageItem[]>([]); const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null); const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
@@ -40,7 +42,7 @@ export function useNotificationCenterData(drawerOpen: boolean) {
setListError(null); setListError(null);
try { try {
const response = await fetchNotificationMessages({ limit: 30 }); const response = await fetchNotificationMessages({ status: listStatus, limit: 30 });
setMessages(response.items); setMessages(response.items);
} catch (error) { } catch (error) {
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.'); setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
@@ -53,7 +55,10 @@ export function useNotificationCenterData(drawerOpen: boolean) {
setSelectedMessage(nextItem); setSelectedMessage(nextItem);
setMessages((current) => { setMessages((current) => {
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false; 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) { if (wasUnread !== nextItem.read) {
void refreshNotificationUnreadCount(); void refreshNotificationUnreadCount();
@@ -149,9 +154,11 @@ export function useNotificationCenterData(drawerOpen: boolean) {
} }
void loadMessages(); void loadMessages();
}, [drawerOpen]); }, [drawerOpen, listStatus]);
return { return {
listStatus,
setListStatus,
unreadCount, unreadCount,
detailOpen, detailOpen,
setDetailOpen, setDetailOpen,

View File

@@ -4,8 +4,7 @@ import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer'; import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
import { useAppStore } from '../../../store'; import { useAppStore } from '../../../store';
import { useTokenAccess } from '../tokenAccess'; import { useTokenAccess } from '../tokenAccess';
import { useAppConfig } from '../appConfig'; import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
import { ChatNotificationBridgeV2 } from '../ChatNotificationBridgeV2';
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2'; import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts'; import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils'; import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
@@ -32,7 +31,6 @@ import {
PLAN_MENU_ANCHOR_IDS, PLAN_MENU_ANCHOR_IDS,
renderSidebarIntro, renderSidebarIntro,
resolveCurrentPageDescriptor, resolveCurrentPageDescriptor,
resolvePlanOpenKeys,
resolvePlanQuickFilterMenu, resolvePlanQuickFilterMenu,
resolvePlayOpenKeys, resolvePlayOpenKeys,
resolveSavedLayoutIdFromMenuKey, resolveSavedLayoutIdFromMenuKey,
@@ -148,16 +146,37 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
return !hasAccess && topMenu !== 'docs'; return !hasAccess && topMenu !== 'docs';
} }
function resolveSidebarOpenKeys(topMenu: TopMenuKey, hasAccess: boolean) { function resolveSidebarOpenKeys(
topMenu: TopMenuKey,
hasAccess: boolean,
planMenu: PlanSectionKey,
chatMenu: ChatSectionKey,
) {
if (!hasAccess) { if (!hasAccess) {
return ['docs-group']; return ['docs-group'];
} }
if (topMenu === 'docs' || topMenu === 'apis') { if (topMenu === 'docs') {
return ['docs-group', 'api-group']; 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() { export function MainLayout() {
@@ -173,7 +192,9 @@ export function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [contentExpanded, setContentExpanded] = useState(false); const [contentExpanded, setContentExpanded] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(resolveSidebarOpenKeys(routeState.topMenu, hasAccess)); const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
);
const [activePlanQuickFilter, setActivePlanQuickFilter] = useState< const [activePlanQuickFilter, setActivePlanQuickFilter] = useState<
'working' | 'release-pending-main' | 'automation-failed' | null 'working' | 'release-pending-main' | 'automation-failed' | null
>(routeState.planMenu === 'release' ? 'release-pending-main' : 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 { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts(); const { chatUnreadCount } = useUnreadCounts();
useEffect(() => {
void syncAppConfigFromServer();
}, []);
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)'); const mediaQuery = window.matchMedia('(max-width: 768px)');
const updateViewport = () => { const updateViewport = () => {
@@ -196,14 +221,17 @@ export function MainLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isMobileViewport) { if (!isMobileViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(false);
return;
} }
}, [isMobileViewport]);
setSidebarCollapsed(routeState.topMenu !== 'docs');
}, [isMobileViewport, routeState.topMenu]);
useEffect(() => { useEffect(() => {
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess)); setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
}, [hasAccess, routeState.topMenu]); }, [hasAccess, routeState.chatMenu, routeState.planMenu, routeState.topMenu]);
useEffect(() => { useEffect(() => {
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) { 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 planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]); const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]); const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
const initialSelectedPlanId = Number(searchParams.get('planId')); const initialSelectedPlanId = Number(searchParams.get('planId'));
const initialSelectedWorkId = searchParams.get('workId'); const initialSelectedWorkId = searchParams.get('workId');
@@ -371,7 +400,6 @@ export function MainLayout() {
> >
<Layout className="app-shell app-shell--docs-api"> <Layout className="app-shell app-shell--docs-api">
<ChatRuntimeBridgeV2 /> <ChatRuntimeBridgeV2 />
<ChatNotificationBridgeV2 />
{contentExpanded ? null : ( {contentExpanded ? null : (
<MainHeader <MainHeader
activeTopMenu={routeState.topMenu} activeTopMenu={routeState.topMenu}
@@ -400,12 +428,13 @@ export function MainLayout() {
)} )}
<Layout> <Layout>
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : ( {contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
<MainSidebar <MainSidebar
activeTopMenu={routeState.topMenu} activeTopMenu={routeState.topMenu}
hasAccess={hasAccess} hasAccess={hasAccess}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
isMobileViewport={isMobileViewport} isMobileViewport={isMobileViewport}
mobileInline={showInlineMobileDocsSidebar}
openKeys={sidebarOpenKeys} openKeys={sidebarOpenKeys}
apiMenuItems={apiMenuItems} apiMenuItems={apiMenuItems}
docsMenuItems={docsMenuItems} docsMenuItems={docsMenuItems}
@@ -426,7 +455,7 @@ export function MainLayout() {
}} }}
onSelectDocsMenu={(key) => { onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key)); navigate(buildDocsPath(key));
if (isMobileViewport) { if (isMobileViewport && !showInlineMobileDocsSidebar) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
@@ -457,7 +486,7 @@ export function MainLayout() {
/> />
)} )}
{isMobileViewport && !sidebarCollapsed ? null : ( {isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}> <MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet /> <Outlet />
</MainContent> </MainContent>

View File

@@ -1,10 +1,13 @@
import { import {
CodeOutlined,
CloseOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
DownOutlined, DownOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
LinkOutlined, FullscreenExitOutlined,
FullscreenOutlined,
MessageOutlined, MessageOutlined,
PaperClipOutlined, PaperClipOutlined,
PlusOutlined, PlusOutlined,
@@ -14,11 +17,25 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
UpOutlined, UpOutlined,
} from '@ant-design/icons'; } 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 type { TextAreaRef } from 'antd/es/input/TextArea';
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type RefObject, type TouchEvent } from 'react'; import {
import { ChatPreviewBody } from './ChatPreviewBody'; 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 { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
@@ -44,6 +61,7 @@ type ChatTypeOption = {
type PreviewOption = { type PreviewOption = {
id: string; id: string;
label: string; label: string;
url: string;
kind: string; kind: string;
}; };
@@ -53,7 +71,7 @@ type QueuedRequestOption = {
text: string; text: string;
}; };
type InlinePreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; type InlinePreviewKind = ChatPreviewKind;
type InlinePreviewTarget = { type InlinePreviewTarget = {
url: string; url: string;
@@ -66,9 +84,17 @@ type PreviewFetchError = Error & {
}; };
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g; 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 CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; 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) { function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value); return normalizeChatResourceUrl(value);
@@ -89,6 +115,10 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
return 'markdown'; 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)) { 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'; return 'code';
} }
@@ -104,6 +134,22 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
return 'file'; 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) { function buildInlinePreviewLabel(url: string) {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@@ -143,7 +189,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
} }
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? []; const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>(); const seen = new Set<string>();
const targets: InlinePreviewTarget[] = []; const targets: InlinePreviewTarget[] = [];
@@ -170,6 +216,81 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
return targets; 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(
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer">
{label.trim() || href}
</a>,
);
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 (
<div key={`img-${index}`} className="app-chat-message__block app-chat-message__block--image">
<InlineImage
src={src}
alt={alt.trim() || 'chat image'}
className="app-chat-message__inline-image markdown-preview__image"
fallbackText="이미지 preview를 불러오지 못했습니다."
/>
</div>
);
}
if (!line.length) {
return <div key={`space-${index}`} className="app-chat-message__block app-chat-message__block--spacer" aria-hidden="true" />;
}
return (
<div key={`line-${index}`} className="app-chat-message__block">
{renderMessageInlineParts(line)}
</div>
);
});
}
function extractMessageRenderPayload(text: string): MessageRenderPayload {
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
return {
visibleText,
diffBlocks,
};
}
function summarizeQueuedText(text: string) { function summarizeQueuedText(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim(); const normalized = text.replace(/\s+/g, ' ').trim();
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized; return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
@@ -289,10 +410,14 @@ function getRequestDetailText(request: ChatConversationRequest | undefined) {
function InlineMessagePreview({ function InlineMessagePreview({
target, target,
isExpanded, isExpanded,
hasModalPreview,
onOpenModalPreview,
onToggle, onToggle,
}: { }: {
target: InlinePreviewTarget; target: InlinePreviewTarget;
isExpanded: boolean; isExpanded: boolean;
hasModalPreview: boolean;
onOpenModalPreview: () => void;
onToggle: () => void; onToggle: () => void;
}) { }) {
const [textPreview, setTextPreview] = useState(''); const [textPreview, setTextPreview] = useState('');
@@ -347,26 +472,77 @@ function InlineMessagePreview({
}; };
}, [isExpanded, target.kind, target.url]); }, [isExpanded, target.kind, target.url]);
const handleCopyPreview = () => {
void copyPreviewContent({
kind: target.kind,
url: target.url,
fallbackText: textPreview,
})
.then((result) => {
if (result === 'image') {
message.success('preview 이미지를 복사했습니다.');
return;
}
if (result === 'url') {
message.success('preview 이미지 URL을 복사했습니다.');
return;
}
message.success('preview 내용을 복사했습니다.');
})
.catch((error: unknown) => {
message.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.');
});
};
return ( return (
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}> <section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
<div className="app-chat-preview-card__header"> <div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta"> <div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true"> <span className="app-chat-preview-card__glyph" aria-hidden="true">
<LinkOutlined /> {resolveChatPreviewGlyph(target.kind)}
</span> </span>
<div className="app-chat-preview-card__titles"> <div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.label}</span> <span className="app-chat-preview-card__label">{target.label}</span>
<span className="app-chat-preview-card__kind">{target.kind}</span> <span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
</div> </div>
</div> </div>
<Button <div className="app-chat-preview-card__actions">
type="link" <Button
size="small" type="text"
className="app-chat-preview-card__toggle" size="small"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />} className="app-chat-preview-card__action"
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'} icon={<CopyOutlined />}
onClick={onToggle} aria-label="preview 내용 복사"
/> onClick={handleCopyPreview}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={hasModalPreview && isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
aria-label={hasModalPreview && isExpanded ? 'preview 100% 닫기' : 'preview 100%'}
onClick={hasModalPreview ? onOpenModalPreview : onToggle}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<DownloadOutlined />}
aria-label="preview 다운로드"
href={target.url}
download
/>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
onClick={onToggle}
/>
</div>
</div> </div>
{isExpanded ? ( {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 (
<section
className={`app-chat-preview-card${isExpanded || isFullscreen ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}${
isFullscreen ? ' app-chat-preview-card--fullscreen' : ''
}`}
>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<CodeOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">Codex Diff</span>
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}`}</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<CopyOutlined />}
aria-label="diff 복사"
onClick={handleCopyDiff}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
aria-label={isFullscreen ? 'diff 최대화 해제' : 'diff 최대화'}
onClick={onToggleFullscreen}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__action"
icon={<DownloadOutlined />}
aria-label="diff 다운로드"
onClick={() => {
downloadTextFile(diffText, 'codex-result.diff', 'text/x-diff;charset=utf-8');
}}
/>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded || isFullscreen ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded || isFullscreen ? 'diff 접기' : 'diff 펼치기'}
onClick={onToggle}
/>
</div>
</div>
{isExpanded || isFullscreen ? (
<div className="app-chat-preview-card__body">
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
<CodexDiffBlock
diffText={diffText}
showToolbar={false}
expandAll={isFullscreen}
summary={`파일 ${fileCount}개 diff preview`}
/>
</div>
</div>
) : null}
</section>
);
}
type ChatConversationViewProps = { type ChatConversationViewProps = {
viewportRef: RefObject<HTMLDivElement | null>; viewportRef: RefObject<HTMLDivElement | null>;
composerRef: RefObject<TextAreaRef | null>; composerRef: RefObject<TextAreaRef | null>;
@@ -418,9 +688,10 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void; onSelectChatType: (value: string) => void;
onSend: () => void; onSend: () => void;
onSendImmediate: () => void; onSendImmediate: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void; onScrollToBottom: () => void;
onToggleResourceStrip: () => void; onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string) => void; onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void; onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void; onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void; onCancelMessage: (message: ChatMessage) => void;
@@ -462,6 +733,7 @@ export function ChatConversationView({
onSelectChatType, onSelectChatType,
onSend, onSend,
onSendImmediate, onSendImmediate,
onClearDraft,
onScrollToBottom, onScrollToBottom,
onToggleResourceStrip, onToggleResourceStrip,
onOpenPreview, onOpenPreview,
@@ -473,34 +745,88 @@ export function ChatConversationView({
}: ChatConversationViewProps) { }: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]); const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null); const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]); const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]); const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>()); const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>()); const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>()); const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const orderedMessages = useMemo(() => { const orderedMessages = useMemo(() => {
const lastActivityIndexByKey = new Map<string, number>(); const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
visibleMessages.forEach((message, index) => { const baseMessages = visibleMessages.filter((message) => {
if (!isActivityLogMessage(message)) {
return;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
lastActivityIndexByKey.set(activityKey, index);
});
return visibleMessages.filter((message, index) => {
if (!isActivityLogMessage(message)) { if (!isActivityLogMessage(message)) {
return true; return true;
} }
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`; const activityKey = message.clientRequestId?.trim();
return lastActivityIndexByKey.get(activityKey) === index;
if (!activityKey) {
orphanActivityMessages.push(message);
return false;
}
latestActivityByRequestId.set(activityKey, message);
return false;
}); });
const insertedActivityRequestIds = new Set<string>();
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]); }, [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) => { const setActivitySectionRef = (requestId: string, element: HTMLElement | null) => {
if (element) { if (element) {
@@ -651,6 +977,28 @@ export function ChatConversationView({
}; };
}, [orderedMessages, expandedMessageIds]); }, [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<HTMLInputElement>) => { const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []); const files = Array.from(event.target.files ?? []);
event.target.value = ''; event.target.value = '';
@@ -662,6 +1010,32 @@ export function ChatConversationView({
void onPickComposerFiles(files); void onPickComposerFiles(files);
}; };
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
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 renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id); const requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId); const isExpanded = !collapsedActivityRequestIds.includes(requestId);
@@ -748,7 +1122,19 @@ export function ChatConversationView({
</div> </div>
) : null} ) : null}
<div className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}`}> {showBusyOverlay ? (
<div className="app-chat-panel__busy-overlay" aria-live="polite" aria-busy="true">
<Spin size="large" />
<strong>{busyOverlayLabel}</strong>
<span> .</span>
</div>
) : null}
<div
className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}${
showBusyOverlay ? ' is-busy' : ''
}`}
>
<div className="app-chat-panel__conversation-toolbar"> <div className="app-chat-panel__conversation-toolbar">
<Button <Button
type={isResourceStripOpen ? 'default' : 'text'} type={isResourceStripOpen ? 'default' : 'text'}
@@ -821,141 +1207,198 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id); const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) { if (isActivityLogMessage(message)) {
return renderActivityCard(message); return renderActivityCard(message);
} }
const inlinePreviewTargets = extractInlinePreviewTargets(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(' ');
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined; const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
const requestStatusLabel = formatRequestStatusLabel(requestState); const requestStatusLabel = formatRequestStatusLabel(requestState);
const requestDetailText = getRequestDetailText(requestState); const requestDetailText = getRequestDetailText(requestState);
return ( return (
<div key={message.id} className={`app-chat-message-stack app-chat-message-stack--${message.author}`}> <div key={message.id} className={stackClassName}>
<article className={`app-chat-message app-chat-message--${message.author}`}> {shouldRenderStandalonePreview ? null : (
<div className="app-chat-message__header"> <article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header-meta"> <div className="app-chat-message__header">
<strong>{message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'}</strong> <div className="app-chat-message__header-meta">
<span>{formatChatTimestamp(message.timestamp)}</span> <strong>{message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'}</strong>
{message.author === 'user' && requestStatusLabel ? ( <span>{formatChatTimestamp(message.timestamp)}</span>
<span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}> {message.author === 'user' && requestStatusLabel ? (
<span>{requestStatusLabel}</span> <span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
</span> <span>{requestStatusLabel}</span>
) : null} </span>
{message.author === 'user' && message.deliveryStatus === 'retrying' ? ( ) : null}
<span className="app-chat-message__status app-chat-message__status--retrying" aria-label="재전송 중"> {message.author === 'user' && message.deliveryStatus === 'retrying' ? (
<SyncOutlined spin /> <span className="app-chat-message__status app-chat-message__status--retrying" aria-label="재전송 중">
<span>{message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'}</span> <SyncOutlined spin />
</span> <span>{message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'}</span>
) : null} </span>
{message.author === 'user' && message.deliveryStatus === 'failed' ? ( ) : null}
<span className="app-chat-message__status app-chat-message__status--failed" aria-label="전송 실패"> {message.author === 'user' && message.deliveryStatus === 'failed' ? (
<ExclamationCircleOutlined /> <span className="app-chat-message__status app-chat-message__status--failed" aria-label="전송 실패">
<span> </span> <ExclamationCircleOutlined />
</span> <span> </span>
) : null} </span>
{message.author === 'user' && ) : null}
(message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? ( {message.author === 'user' &&
<Button (message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? (
type="link"
size="small"
danger
className="app-chat-message__cancel"
icon={<CloseOutlined />}
onClick={() => {
onCancelMessage(message);
}}
>
</Button>
) : null}
{message.author === 'user' && message.deliveryStatus === 'failed' ? (
<>
<Button <Button
type="link" type="link"
size="small" size="small"
className="app-chat-message__retry" danger
icon={<RedoOutlined />} className="app-chat-message__cancel"
icon={<CloseOutlined />}
onClick={() => { onClick={() => {
onRetryMessage(message); onCancelMessage(message);
}} }}
> >
</Button> </Button>
</> ) : null}
) : null} {message.author === 'user' && message.deliveryStatus === 'failed' ? (
{message.author === 'user' && <>
requestState?.canDelete && <Button
requestState.status !== 'accepted' ? ( type="link"
size="small"
className="app-chat-message__retry"
icon={<RedoOutlined />}
onClick={() => {
onRetryMessage(message);
}}
>
</Button>
</>
) : null}
{message.author === 'user' &&
requestState?.canDelete &&
requestState.status !== 'accepted' ? (
<Button
type="link"
size="small"
className="app-chat-message__retry app-chat-message__delete"
icon={<DeleteOutlined />}
aria-label="메시지 삭제"
onClick={() => {
onDeleteRequest(message);
}}
/>
) : null}
</div>
{message.author !== 'system' ? (
<Button <Button
type="link" type="text"
size="small" size="small"
className="app-chat-message__retry app-chat-message__delete" className="app-chat-message__header-action"
icon={<DeleteOutlined />} icon={<CopyOutlined />}
aria-label="메시지 삭제" aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
onClick={() => { onClick={() => {
onDeleteRequest(message); onCopyMessage(message);
}} }}
/> />
) : null} ) : null}
</div> </div>
{message.author !== 'system' ? ( <div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{visibleText ? renderMessageBody(visibleText) : null}
</div>
{message.author === 'user' && requestDetailText ? (
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
{requestDetailText}
</div>
) : null}
{canCollapseMessage ? (
<Button <Button
type="text" type="text"
size="small" size="small"
className="app-chat-message__header-action" className="app-chat-message__expand"
icon={<CopyOutlined />} icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '메시지 복사' : '답변 복사'} aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
onClick={() => { onClick={() => {
onCopyMessage(message); setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}} }}
/> >
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null} ) : null}
</div> </article>
<div )}
ref={(element) => { {hasPreviewCards ? (
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{message.text}
</div>
{message.author === 'user' && requestDetailText ? (
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
{requestDetailText}
</div>
) : null}
{canCollapseMessage ? (
<Button
type="text"
size="small"
className="app-chat-message__expand"
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
onClick={() => {
setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}}
>
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null}
</article>
{inlinePreviewTargets.length > 0 ? (
<div className="app-chat-message-stack__previews"> <div className="app-chat-message-stack__previews">
{inlinePreviewTargets.map((target) => ( {diffBlocks.map((diffText, index) => {
<InlineMessagePreview const previewKey = `${message.id}-diff-${index}`;
key={`${message.id}-${target.url}`}
target={target} return (
isExpanded={expandedPreviewKey === `${message.id}-${target.url}`} <DiffMessagePreview
onToggle={() => { key={previewKey}
const nextKey = `${message.id}-${target.url}`; diffText={diffText}
setExpandedPreviewKey((current) => (current === nextKey ? null : nextKey)); fileCount={Math.max(1, Array.from(diffText.matchAll(/^diff --git /gm)).length)}
}} isExpanded={expandedPreviewKey === previewKey}
/> isFullscreen={fullscreenPreviewKey === previewKey}
))} onToggle={() => {
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 (
<InlineMessagePreview
key={previewKey}
target={target}
isExpanded={expandedPreviewKey === previewKey}
hasModalPreview={Boolean(matchedPreview)}
onOpenModalPreview={() => {
if (matchedPreview) {
onOpenPreview(matchedPreview.id, { fullscreen: true });
return;
}
}}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
}}
/>
);
})}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1094,6 +1537,7 @@ export function ChatConversationView({
onChange={(event) => { onChange={(event) => {
onDraftChange(event.target.value); onDraftChange(event.target.value);
}} }}
onPaste={handleComposerPaste}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) { if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return; return;
@@ -1113,6 +1557,16 @@ export function ChatConversationView({
onSend(); onSend();
}} }}
/> />
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${draft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!draft.trim()}
>
clear
</Button>
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@@ -1,14 +1,25 @@
import { DownloadOutlined, EyeOutlined } from '@ant-design/icons'; import {
CodeOutlined,
DownloadOutlined,
EyeOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileTextOutlined,
LinkOutlined,
PictureOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd'; import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
import { InlineImage } from '../../../components/common/InlineImage'; import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent'; import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer'; import { CodexDiffBlock } from '../../../components/previewer';
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers'; import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
import { triggerResourceDownload } from './downloadUtils';
import '../../../components/previewer/PreviewerUI.css'; import '../../../components/previewer/PreviewerUI.css';
const { Paragraph, Text } = Typography; const { Paragraph, Text } = Typography;
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
export type ChatPreviewTarget = { export type ChatPreviewTarget = {
label: string; label: string;
@@ -16,6 +27,47 @@ export type ChatPreviewTarget = {
kind: ChatPreviewKind; kind: ChatPreviewKind;
}; };
export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
switch (kind) {
case 'image':
return <PictureOutlined />;
case 'video':
return <VideoCameraOutlined />;
case 'markdown':
return <FileMarkdownOutlined />;
case 'code':
case 'diff':
return <CodeOutlined />;
case 'document':
return <FileTextOutlined />;
case 'pdf':
return <FilePdfOutlined />;
default:
return <LinkOutlined />;
}
}
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) { function resolvePreviewErrorMessage(previewError: string) {
const normalized = previewError.trim(); 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); const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (resolvedLanguage === 'diff') { if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return ( return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code"> <div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
<CodexDiffBlock <CodexDiffBlock
@@ -291,12 +343,16 @@ export function ChatPreviewBody({
. . . .
</Paragraph> </Paragraph>
<Space wrap> <Space wrap>
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}> <Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button
</Button> type="text"
<Button href={target.url} download icon={<DownloadOutlined />}> aria-label="다운로드"
icon={<DownloadOutlined />}
</Button> onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
</Space> </Space>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity'; import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
import { getRegisteredAccessToken } from '../tokenAccess'; import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
import { reportClientError } from '../errorLogApi'; import { reportClientError } from '../errorLogApi';
import type { import type {
ChatActivityEvent, ChatActivityEvent,
@@ -17,28 +17,66 @@ import type {
ChatViewContext, ChatViewContext,
} from './types'; } from './types';
const CONNECT_TIMEOUT_MS = 8000; const CONNECT_TIMEOUT_MS = 20000;
const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id'; 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_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:'; 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_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.'; const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500; const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
const chatSessionLastTypeMemory = new Map<string, string>();
const chatLastEventIdMemory = new Map<string, number>();
const chatOfflineNotificationMemory = new Map<string, boolean>();
let chatClientSessionIdMemory = '';
let localMessageSequence = 0; let localMessageSequence = 0;
let cachedChatConversationList: ChatConversationSummary[] | null = null; let cachedChatConversationList: ChatConversationSummary[] | null = null;
let cachedChatConversationListAt = 0; let cachedChatConversationListAt = 0;
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null; let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | 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 = { export const CHAT_CONNECTION = {
reconnectDelayMs: 3000, reconnectDelayMs: 1500,
connectTimeoutMs: CONNECT_TIMEOUT_MS, connectTimeoutMs: CONNECT_TIMEOUT_MS,
sessionIdKey: CHAT_SESSION_ID_KEY, sessionIdKey: CHAT_SESSION_ID_KEY,
lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX, lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX,
notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX, notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX,
sessionMessagesStoragePrefix: CHAT_SESSION_MESSAGES_STORAGE_PREFIX,
sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX, sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX,
introMessage: CHAT_INTRO_MESSAGE, introMessage: CHAT_INTRO_MESSAGE,
} as const; } as const;
@@ -49,21 +87,6 @@ function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | nul
return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`; 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() { function createBrowserSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -77,29 +100,10 @@ export function clearStoredChatClientConversationState() {
return; return;
} }
const keysToRemove: string[] = []; chatClientSessionIdMemory = '';
chatSessionLastTypeMemory.clear();
for (let index = 0; index < window.localStorage.length; index += 1) { chatLastEventIdMemory.clear();
const key = window.localStorage.key(index); chatOfflineNotificationMemory.clear();
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);
});
} }
function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest { function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest {
@@ -123,15 +127,12 @@ export function getChatClientSessionId() {
return ''; return '';
} }
const existing = window.localStorage.getItem(CHAT_CONNECTION.sessionIdKey)?.trim(); if (chatClientSessionIdMemory) {
return chatClientSessionIdMemory;
if (existing) {
return existing;
} }
const nextSessionId = createBrowserSessionId(); chatClientSessionIdMemory = createBrowserSessionId();
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, nextSessionId); return chatClientSessionIdMemory;
return nextSessionId;
} }
export function setChatClientSessionId(sessionId: string) { export function setChatClientSessionId(sessionId: string) {
@@ -145,7 +146,7 @@ export function setChatClientSessionId(sessionId: string) {
return; return;
} }
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, normalizedSessionId); chatClientSessionIdMemory = normalizedSessionId;
} }
export function getLastReceivedChatEventId(sessionId: string) { export function getLastReceivedChatEventId(sessionId: string) {
@@ -159,9 +160,7 @@ export function getLastReceivedChatEventId(sessionId: string) {
return 0; return 0;
} }
const raw = window.localStorage.getItem(buildLastEventIdStorageKey(normalizedSessionId)); return chatLastEventIdMemory.get(normalizedSessionId) ?? 0;
const parsed = raw ? Number(raw) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
} }
export function persistLastReceivedChatEventId(sessionId: string, eventId: number) { export function persistLastReceivedChatEventId(sessionId: string, eventId: number) {
@@ -181,7 +180,7 @@ export function persistLastReceivedChatEventId(sessionId: string, eventId: numbe
return; return;
} }
window.localStorage.setItem(buildLastEventIdStorageKey(normalizedSessionId), String(eventId)); chatLastEventIdMemory.set(normalizedSessionId, eventId);
} }
export function resetLastReceivedChatEventId(sessionId: string) { export function resetLastReceivedChatEventId(sessionId: string) {
@@ -195,7 +194,7 @@ export function resetLastReceivedChatEventId(sessionId: string) {
return; return;
} }
window.localStorage.removeItem(buildLastEventIdStorageKey(normalizedSessionId)); chatLastEventIdMemory.delete(normalizedSessionId);
} }
export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
@@ -203,13 +202,13 @@ export function getStoredChatOfflineNotificationSetting(sessionId: string, clien
return null; 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 null;
} }
return raw === 'true'; return chatOfflineNotificationMemory.get(key) ?? null;
} }
export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) { export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) {
@@ -217,7 +216,7 @@ export function setStoredChatOfflineNotificationSetting(sessionId: string, enabl
return; return;
} }
window.localStorage.setItem(buildNotifyOfflineStorageKey(sessionId, clientId), enabled ? 'true' : 'false'); chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled);
} }
export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
@@ -225,7 +224,7 @@ export function clearStoredChatOfflineNotificationSetting(sessionId: string, cli
return; return;
} }
window.localStorage.removeItem(buildNotifyOfflineStorageKey(sessionId, clientId)); chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId));
} }
function resolveSyncedChatOfflineNotificationSetting( function resolveSyncedChatOfflineNotificationSetting(
@@ -247,106 +246,31 @@ function resolveSyncedChatOfflineNotificationSetting(
return serverValue; 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) { export function getStoredChatSessionLastTypeId(sessionId: string) {
if (typeof window === 'undefined') {
return null;
}
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) { if (!normalizedSessionId) {
return null; return null;
} }
const raw = window.localStorage.getItem(buildSessionLastChatTypeStorageKey(normalizedSessionId))?.trim() ?? ''; const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? '';
return raw || null; return raw || null;
} }
export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) { export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) {
if (typeof window === 'undefined') {
return;
}
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
const normalizedChatTypeId = chatTypeId.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) { if (!normalizedSessionId) {
return; return;
} }
window.localStorage.setItem( if (!normalizedChatTypeId) {
buildSessionMessagesStorageKey(normalizedSessionId), chatSessionLastTypeMemory.delete(normalizedSessionId);
JSON.stringify(messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message))),
);
}
export function clearStoredChatMessages(sessionId: string) {
if (typeof window === 'undefined') {
return; return;
} }
const normalizedSessionId = sessionId.trim(); chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId);
if (!normalizedSessionId) {
return;
}
window.localStorage.removeItem(buildSessionMessagesStorageKey(normalizedSessionId));
} }
export function formatTime(date: Date) { export function formatTime(date: Date) {
@@ -369,6 +293,20 @@ function createLocalMessageId() {
return Date.now() * 1_000 + localMessageSequence; 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) { function hashRequestId(value: string) {
let hash = 0; let hash = 0;
@@ -606,6 +544,7 @@ export function buildOfflineReply(context: ChatViewContext, input: string) {
export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) { export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) {
const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL; const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL;
const resolvedClientId = clientId || getOrCreateClientId(); const resolvedClientId = clientId || getOrCreateClientId();
const accessToken = getRegisteredAccessToken();
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return '';
@@ -626,6 +565,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
if (resolvedClientId) { if (resolvedClientId) {
normalizedUrl.searchParams.set('clientId', resolvedClientId); normalizedUrl.searchParams.set('clientId', resolvedClientId);
} }
if (accessToken) {
normalizedUrl.searchParams.set('accessToken', accessToken);
}
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
normalizedUrl.searchParams.set('lastEventId', String(lastEventId)); normalizedUrl.searchParams.set('lastEventId', String(lastEventId));
} }
@@ -641,6 +583,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
if (resolvedClientId) { if (resolvedClientId) {
url.searchParams.set('clientId', resolvedClientId); url.searchParams.set('clientId', resolvedClientId);
} }
if (accessToken) {
url.searchParams.set('accessToken', accessToken);
}
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
url.searchParams.set('lastEventId', String(lastEventId)); 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<PreviewCopyResult> {
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<PreviewCopyResult> {
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() { function resolveChatApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) { if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL; return import.meta.env.VITE_WORK_SERVER_URL;
@@ -751,6 +796,11 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 8000); const timeoutId = window.setTimeout(() => controller.abort(), 8000);
if (!hasRegisteredAccessTokenAccess()) {
window.clearTimeout(timeoutId);
throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.');
}
if (accessToken && !headers.has('X-Access-Token')) { if (accessToken && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', accessToken); headers.set('X-Access-Token', accessToken);
} }
@@ -775,17 +825,43 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error('채팅 서버 응답이 지연됩니다.'); throw new Error('채팅 서버 응답이 지연됩니다.');
} }
throw error; throw new Error('채팅 서버 연결에 실패했습니다.');
} }
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
const text = await response.text(); 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<T>; 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) { async function readFileAsBase64(file: File) {
@@ -827,10 +903,12 @@ export async function fetchChatConversations() {
const clientId = getOrCreateClientId(); const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations') chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
.then((response) => { .then((response) => {
const items = response.items.map((item) => ({ const items = sortChatConversationSummaries(
...item, response.items.map((item) => ({
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId), ...item,
})); notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
})),
);
cachedChatConversationList = items; cachedChatConversationList = items;
cachedChatConversationListAt = Date.now(); cachedChatConversationListAt = Date.now();
@@ -864,19 +942,23 @@ export async function fetchChatConversationDetail(
const response = await requestChatApi<ChatConversationDetailResponse>( const response = await requestChatApi<ChatConversationDetailResponse>(
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`, `/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
); );
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
const visibleRequestIds = new Set( const visibleRequestIds = new Set(
response.messages response.messages
.map((message) => message.clientRequestId?.trim() ?? '') .map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean), .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 { return {
...response, ...response,
messages: hydrateActivityLogMessages( messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
response.messages,
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
),
item: { item: {
...response.item, ...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting( notifyOffline: resolveSyncedChatOfflineNotificationSetting(
@@ -885,7 +967,7 @@ export async function fetchChatConversationDetail(
clientId, 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: { export async function createChatConversationRoom(args: {
sessionId: string; sessionId: string;
title?: string; title?: string;
chatTypeId?: string | null;
contextLabel?: string; contextLabel?: string;
contextDescription?: string; contextDescription?: string;
notifyOffline?: boolean; notifyOffline?: boolean;
@@ -956,6 +1039,7 @@ export async function createChatConversationRoom(args: {
body: JSON.stringify({ body: JSON.stringify({
sessionId: args.sessionId, sessionId: args.sessionId,
title: args.title ?? '새 대화', title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
contextLabel: args.contextLabel ?? null, contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null, contextDescription: args.contextDescription ?? null,
notifyOffline, notifyOffline,
@@ -963,6 +1047,8 @@ export async function createChatConversationRoom(args: {
}), }),
}); });
invalidateChatConversationListCache();
return { return {
...response.item, ...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), 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; return response.item;
} }
@@ -987,6 +1075,9 @@ export async function updateChatConversationRoom(
sessionId: string, sessionId: string,
payload: { payload: {
title?: string; title?: string;
chatTypeId?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean; notifyOffline?: boolean;
}, },
) { ) {
@@ -999,6 +1090,8 @@ export async function updateChatConversationRoom(
}, },
); );
invalidateChatConversationListCache();
return { return {
...response.item, ...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
@@ -1025,6 +1118,8 @@ export async function deleteChatConversationRoom(sessionId: string) {
}, },
); );
invalidateChatConversationListCache();
return response; return response;
} }
@@ -1116,8 +1211,8 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
} }
return Boolean( return Boolean(
left.author === 'user' && (left.author === 'user' || left.author === 'codex') &&
right.author === 'user' && left.author === right.author &&
left.clientRequestId && left.clientRequestId &&
right.clientRequestId && right.clientRequestId &&
left.clientRequestId === right.clientRequestId, left.clientRequestId === right.clientRequestId,
@@ -1133,9 +1228,78 @@ function buildComparableChatMessageKey(message: ChatMessage) {
return `user-request:${message.clientRequestId}`; return `user-request:${message.clientRequestId}`;
} }
if (message.author === 'codex' && message.clientRequestId) {
return `codex-request:${message.clientRequestId}`;
}
return `id:${message.id}`; 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[]) { export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
if (previous.length === 0) { if (previous.length === 0) {
return incoming; return incoming;

View File

@@ -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);
}

View File

@@ -4,7 +4,9 @@ export { ErrorLogViewer } from './ErrorLogViewer';
export { export {
buildOfflineReply, buildOfflineReply,
clearStoredChatClientConversationState, clearStoredChatClientConversationState,
copyPreviewContent,
copyText, copyText,
resolvePreviewBodyForCopy,
createActivityLogPlaceholder, createActivityLogPlaceholder,
createChatConversationRoom, createChatConversationRoom,
createChatMessage, createChatMessage,
@@ -20,15 +22,14 @@ export {
getStoredChatSessionLastTypeId, getStoredChatSessionLastTypeId,
isPreparingChatReplyText, isPreparingChatReplyText,
getChatClientSessionId, getChatClientSessionId,
loadStoredChatMessages,
markChatConversationResponsesRead, markChatConversationResponsesRead,
mergeRecoveredChatMessages, mergeRecoveredChatMessages,
persistStoredChatMessages,
renameChatConversationRoom, renameChatConversationRoom,
removeChatRuntimeJob, removeChatRuntimeJob,
resetLastReceivedChatEventId, resetLastReceivedChatEventId,
setStoredChatSessionLastTypeId, setStoredChatSessionLastTypeId,
setChatClientSessionId, setChatClientSessionId,
sortChatConversationSummaries,
uploadChatComposerFile, uploadChatComposerFile,
upsertChatMessage, upsertChatMessage,
updateChatConversationRoom, updateChatConversationRoom,

View File

@@ -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();
}

View File

@@ -37,6 +37,7 @@ export type ChatConversationSummary = {
sessionId: string; sessionId: string;
clientId: string | null; clientId: string | null;
title: string; title: string;
chatTypeId: string | null;
contextLabel: string | null; contextLabel: string | null;
contextDescription: string | null; contextDescription: string | null;
notifyOffline: boolean; notifyOffline: boolean;

View File

@@ -8,6 +8,7 @@ import {
persistLastReceivedChatEventId, persistLastReceivedChatEventId,
resolveChatWebSocketUrl, resolveChatWebSocketUrl,
} from './chatUtils'; } from './chatUtils';
import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
import type { import type {
ChatActivityEvent, ChatActivityEvent,
ChatJobEvent, ChatJobEvent,
@@ -19,7 +20,6 @@ import type {
const DISCONNECT_UI_DELAY_MS = 1500; const DISCONNECT_UI_DELAY_MS = 1500;
const PRESENCE_PING_INTERVAL_MS = 20_000; const PRESENCE_PING_INTERVAL_MS = 20_000;
const BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS = 15_000;
type ConnectionState = 'connecting' | 'connected' | 'disconnected'; type ConnectionState = 'connecting' | 'connected' | 'disconnected';
@@ -225,14 +225,13 @@ function sendPresencePing() {
); );
} }
function refreshSharedSocket() { function ensureSharedSocket() {
const socket = sharedChatConnection.socketRef.current; const socket = sharedChatConnection.socketRef.current;
if (socket && socket.readyState === WebSocket.CONNECTING) { if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return; return;
} }
disconnectSharedSocket();
connectSharedSocket(); 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() { function handleVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
sharedChatConnection.lastBackgroundAt = Date.now(); sharedChatConnection.lastBackgroundAt = Date.now();
@@ -306,36 +287,24 @@ function handleVisibilityChange() {
return; return;
} }
if (shouldRefreshSocketAfterResume()) { ensureSharedSocket();
refreshSharedSocket();
return;
}
sendPresencePing(); sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext); sendContextUpdate(sharedChatConnection.currentContext);
} }
function handlePageShow() { function handlePageShow() {
if (shouldRefreshSocketAfterResume()) { ensureSharedSocket();
refreshSharedSocket();
return;
}
sendPresencePing(); sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext); sendContextUpdate(sharedChatConnection.currentContext);
} }
function handleWindowFocus() { function handleWindowFocus() {
if (shouldRefreshSocketAfterResume()) { ensureSharedSocket();
refreshSharedSocket();
return;
}
sendPresencePing(); sendPresencePing();
} }
function handleWindowOnline() { function handleWindowOnline() {
refreshSharedSocket(); ensureSharedSocket();
} }
function startPresenceMonitoring() { function startPresenceMonitoring() {
@@ -444,6 +413,16 @@ function connectSharedSocket() {
return; return;
} }
if (!hasRegisteredAccessTokenAccess()) {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
setSharedConnectionState('disconnected');
return;
}
const currentSocket = sharedChatConnection.socketRef.current; const currentSocket = sharedChatConnection.socketRef.current;
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) { if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
@@ -496,6 +475,13 @@ function connectSharedSocket() {
return; return;
} }
if (closeEvent?.code === 1008) {
clearReconnectTimer();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
setSharedConnectionState('disconnected');
return;
}
if (closeEvent?.code === 1000 && !message) { if (closeEvent?.code === 1000 && !message) {
setSharedConnectionError(''); setSharedConnectionError('');
return; return;

View File

@@ -0,0 +1,5 @@
export function registerSW() {
return async function updateServiceWorker() {
return;
};
}

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from 'react';
import type { PlanFilterStatus } from '../../features/planBoard'; import type { PlanFilterStatus } from '../../features/planBoard';
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play'; 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 ApiSectionKey = 'components' | 'widgets';
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command'; export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage'; export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
@@ -370,7 +370,7 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
return buildDocsPath(currentDocsFolder); return buildDocsPath(currentDocsFolder);
} }
return menu === 'plans' ? buildPlansPath('all') : buildPlayPath('layout'); return buildPlansPath('all');
} }
export function createPageWindowId(topMenu: TopMenuKey, section: string) { export function createPageWindowId(topMenu: TopMenuKey, section: string) {

View File

@@ -34,6 +34,7 @@ export type MainSidebarProps = {
hasAccess: boolean; hasAccess: boolean;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
isMobileViewport: boolean; isMobileViewport: boolean;
mobileInline?: boolean;
openKeys: string[]; openKeys: string[];
apiMenuItems: MenuProps['items']; apiMenuItems: MenuProps['items'];
docsMenuItems: MenuProps['items']; docsMenuItems: MenuProps['items'];

View File

@@ -2,11 +2,12 @@ import {
AudioOutlined, AudioOutlined,
CodeOutlined, CodeOutlined,
CopyOutlined, CopyOutlined,
EyeOutlined, DownloadOutlined,
FileImageOutlined, FileImageOutlined,
FileMarkdownOutlined, FileMarkdownOutlined,
FilePdfOutlined, FilePdfOutlined,
FileTextOutlined, FileTextOutlined,
FullscreenOutlined,
LinkOutlined, LinkOutlined,
PlayCircleOutlined, PlayCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -128,6 +129,81 @@ async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
document.body.removeChild(textarea); 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) { function resolvePreviewerType(kind: EvidenceAttachmentKind) {
switch (kind) { switch (kind) {
case 'markdown': case 'markdown':
@@ -260,6 +336,14 @@ export function EvidenceAttachmentStrip({
} }
} }
async function handleDownload(attachment: EvidenceAttachmentItem) {
try {
downloadAttachmentValue(attachment);
} catch {
message.error('다운로드에 실패했습니다.');
}
}
if (attachments.length === 0) { if (attachments.length === 0) {
return ( return (
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}> <div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
@@ -308,29 +392,27 @@ export function EvidenceAttachmentStrip({
void handleCopy(attachment); void handleCopy(attachment);
}} }}
/> />
{attachment.linkUrl ? ( {attachment.linkUrl || attachment.value ? (
<Button <Button
type="link" type="text"
size="small" size="small"
href={attachment.linkUrl} aria-label="다운로드"
target="_blank" icon={<DownloadOutlined />}
rel="noreferrer" onClick={() => {
style={{ paddingInline: 0 }} void handleDownload(attachment);
icon={<LinkOutlined />} }}
> />
</Button>
) : null} ) : null}
{onPreview ? ( {onPreview ? (
<Button <Button
size="small" size="small"
icon={<EyeOutlined />} type="text"
aria-label="최대화"
icon={<FullscreenOutlined />}
onClick={() => { onClick={() => {
void onPreview(attachment); void onPreview(attachment);
}} }}
> />
</Button>
) : null} ) : null}
</Space> </Space>
} }

View File

@@ -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 { useState } from 'react';
import type { SampleMeta } from '../../../widgets/core'; import type { SampleMeta } from '../../../widgets/core';
import { import {
@@ -96,6 +103,7 @@ export function Sample() {
const { message } = App.useApp(); const { message } = App.useApp();
const [compact, setCompact] = useState(false); const [compact, setCompact] = useState(false);
const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null); const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null);
const [isPreviewExpanded, setIsPreviewExpanded] = useState(false);
return ( return (
<Flex vertical gap={16}> <Flex vertical gap={16}>
@@ -130,13 +138,51 @@ export function Sample() {
open={Boolean(selectedAttachment)} open={Boolean(selectedAttachment)}
title={selectedAttachment?.title ?? 'Attachment Preview'} title={selectedAttachment?.title ?? 'Attachment Preview'}
footer={null} footer={null}
width={1080} width={isPreviewExpanded ? 'calc(100vw - 32px)' : 1080}
onCancel={() => { onCancel={() => {
setSelectedAttachment(null); setSelectedAttachment(null);
setIsPreviewExpanded(false);
}} }}
> >
{selectedAttachment ? ( {selectedAttachment ? (
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} /> <Flex vertical gap={12}>
<Flex justify="flex-end" gap={8}>
<Button
aria-label="복사"
icon={<CopyOutlined />}
onClick={async () => {
await navigator.clipboard.writeText(selectedAttachment.copyValue ?? selectedAttachment.value);
message.success(`${String(selectedAttachment.title)} 복사`);
}}
/>
{selectedAttachment.linkUrl ? (
<Button
aria-label="다운로드"
icon={<DownloadOutlined />}
href={selectedAttachment.linkUrl}
target={selectedAttachment.kind === 'preview' ? '_blank' : undefined}
rel={selectedAttachment.kind === 'preview' ? 'noreferrer' : undefined}
download={selectedAttachment.kind === 'preview' ? undefined : true}
/>
) : null}
<Button
aria-label={isPreviewExpanded ? '최대화 해제' : '최대화'}
icon={isPreviewExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => {
setIsPreviewExpanded((previous) => !previous);
}}
/>
<Button
aria-label="닫기"
icon={<CloseOutlined />}
onClick={() => {
setSelectedAttachment(null);
setIsPreviewExpanded(false);
}}
/>
</Flex>
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
</Flex>
) : null} ) : null}
</Modal> </Modal>
</Flex> </Flex>

View File

@@ -30,6 +30,7 @@ type CodexDiffBlockProps = {
summary?: string; summary?: string;
emptyDescription?: string; emptyDescription?: string;
showToolbar?: boolean; showToolbar?: boolean;
expandAll?: boolean;
className?: string; className?: string;
}; };
@@ -170,30 +171,42 @@ export function CodexDiffBlock({
summary, summary,
emptyDescription = '표시할 diff가 없습니다.', emptyDescription = '표시할 diff가 없습니다.',
showToolbar = true, showToolbar = true,
expandAll = false,
className, className,
}: CodexDiffBlockProps) { }: CodexDiffBlockProps) {
const diffSections = useMemo(() => parseCodexDiffSections(diffText), [diffText]); const diffSections = useMemo(() => parseCodexDiffSections(diffText), [diffText]);
const [expandedDiffPaths, setExpandedDiffPaths] = useState<string[]>(() => diffSections.slice(0, 1).map((section) => section.path)); const [expandedDiffPath, setExpandedDiffPath] = useState<string | null>(() =>
expandAll ? diffSections[0]?.path ?? null : diffSections[0]?.path ?? null,
);
useEffect(() => { useEffect(() => {
setExpandedDiffPaths((currentPaths) => { if (expandAll) {
const availablePaths = new Set(diffSections.map((section) => section.path)); setExpandedDiffPath(diffSections[0]?.path ?? null);
const nextPaths = currentPaths.filter((path) => availablePaths.has(path)); return;
}
if (nextPaths.length > 0) { setExpandedDiffPath((currentPath) => {
return nextPaths; if (currentPath && diffSections.some((section) => section.path === currentPath)) {
return currentPath;
} }
return diffSections[0] ? [diffSections[0].path] : []; return diffSections[0]?.path ?? null;
}); });
}, [diffSections]); }, [diffSections, expandAll]);
if (!diffSections.length) { if (!diffSections.length) {
return <Empty description={emptyDescription} />; return <Empty description={emptyDescription} />;
} }
return ( return (
<div className={className ?? 'codex-diff-previewer__diff-list'}> <div
className={[
className ?? 'codex-diff-previewer__diff-list',
expandAll ? 'codex-diff-previewer__diff-list--expand-all' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="codex-diff-previewer__diff-toolbar"> <div className="codex-diff-previewer__diff-toolbar">
<Text type="secondary"> <Text type="secondary">
{summary ?? `파일 ${diffSections.length}개 기준으로 diff를 분리해 표시합니다.`} {summary ?? `파일 ${diffSections.length}개 기준으로 diff를 분리해 표시합니다.`}
@@ -203,15 +216,15 @@ export function CodexDiffBlock({
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
setExpandedDiffPaths(diffSections.map((section) => section.path)); setExpandedDiffPath(diffSections[0]?.path ?? null);
}} }}
> >
diff
</Button> </Button>
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
setExpandedDiffPaths([]); setExpandedDiffPath(null);
}} }}
> >
@@ -221,7 +234,7 @@ export function CodexDiffBlock({
</div> </div>
{diffSections.map((section) => { {diffSections.map((section) => {
const isExpanded = expandedDiffPaths.includes(section.path); const isExpanded = expandedDiffPath === section.path;
const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path; const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path;
return ( return (
@@ -230,11 +243,7 @@ export function CodexDiffBlock({
type="button" type="button"
className="codex-diff-previewer__diff-toggle" className="codex-diff-previewer__diff-toggle"
onClick={() => { onClick={() => {
setExpandedDiffPaths((currentPaths) => setExpandedDiffPath((currentPath) => (currentPath === section.path ? null : section.path));
currentPaths.includes(section.path)
? currentPaths.filter((path) => path !== section.path)
: [...currentPaths, section.path],
);
}} }}
> >
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main"> <Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">

View File

@@ -58,9 +58,10 @@
.codex-diff-previewer__diff-section--expanded { .codex-diff-previewer__diff-section--expanded {
position: fixed; position: fixed;
inset: 16px; inset: 0;
z-index: 1250; z-index: 1250;
border-radius: 20px; border-radius: 0;
border-inline: 0;
background: #0f172a; background: #0f172a;
box-shadow: box-shadow:
0 28px 72px rgba(15, 23, 42, 0.38), 0 28px 72px rgba(15, 23, 42, 0.38),
@@ -95,7 +96,7 @@
} }
.codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body { .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
height: calc(100vh - 68px); height: calc(100vh - 60px);
overflow: auto; overflow: auto;
} }

View File

@@ -1,5 +1,6 @@
import { import {
CopyOutlined, CopyOutlined,
DownloadOutlined,
DownOutlined, DownOutlined,
FileImageOutlined, FileImageOutlined,
FileTextOutlined, FileTextOutlined,
@@ -92,7 +93,7 @@ export function CodexDiffPreviewer({
}: CodexDiffPreviewerProps) { }: CodexDiffPreviewerProps) {
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff'); const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff');
const [expandedSourcePaths, setExpandedSourcePaths] = useState<string[]>(() => files.slice(0, 1).map((file) => file.path)); const [expandedSourcePath, setExpandedSourcePath] = useState<string | null>(() => files[0]?.path ?? null);
const [expandedPreviewPath, setExpandedPreviewPath] = useState<string | null>(null); const [expandedPreviewPath, setExpandedPreviewPath] = useState<string | null>(null);
const statusCount = useMemo(() => buildStatusCount(files), [files]); const statusCount = useMemo(() => buildStatusCount(files), [files]);
const canShowSource = files.length > 0; 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) { function handleFullscreen(path: string) {
setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path)); setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path));
} }
@@ -141,15 +160,12 @@ export function CodexDiffPreviewer({
return; return;
} }
setExpandedSourcePaths((currentPaths) => { setExpandedSourcePath((currentPath) => {
const availablePaths = new Set(files.map((file) => file.path)); if (currentPath && files.some((file) => file.path === currentPath)) {
const nextPaths = currentPaths.filter((path) => availablePaths.has(path)); return currentPath;
if (nextPaths.length > 0) {
return nextPaths;
} }
return files[0] ? [files[0].path] : []; return files[0]?.path ?? null;
}); });
}, [canShowSource, diffText, files]); }, [canShowSource, diffText, files]);
@@ -231,15 +247,15 @@ export function CodexDiffPreviewer({
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
setExpandedSourcePaths(files.map((file) => file.path)); setExpandedSourcePath(files[0]?.path ?? null);
}} }}
> >
</Button> </Button>
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
setExpandedSourcePaths([]); setExpandedSourcePath(null);
}} }}
> >
@@ -248,7 +264,7 @@ export function CodexDiffPreviewer({
</div> </div>
{files.map((file) => { {files.map((file) => {
const isExpanded = expandedSourcePaths.includes(file.path); const isExpanded = expandedSourcePath === file.path;
const isPreviewExpanded = expandedPreviewPath === file.path; const isPreviewExpanded = expandedPreviewPath === file.path;
const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path; const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path;
@@ -266,11 +282,7 @@ export function CodexDiffPreviewer({
type="button" type="button"
className="codex-diff-previewer__diff-toggle" className="codex-diff-previewer__diff-toggle"
onClick={() => { onClick={() => {
setExpandedSourcePaths((currentPaths) => setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path));
currentPaths.includes(file.path)
? currentPaths.filter((path) => path !== file.path)
: [...currentPaths, file.path],
);
}} }}
> >
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main"> <Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
@@ -296,6 +308,16 @@ export function CodexDiffPreviewer({
void handleCopy(file.content); void handleCopy(file.content);
}} }}
/> />
<Button
size="small"
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={(event) => {
event.stopPropagation();
handleDownload(file.path, file.content);
}}
/>
<Button <Button
size="small" size="small"
type="text" type="text"
@@ -316,6 +338,7 @@ export function CodexDiffPreviewer({
value={file.content} value={file.content}
language={file.language} language={file.language}
imageAlt={file.path.split('/').pop() ?? file.path} imageAlt={file.path.split('/').pop() ?? file.path}
downloadFileName={file.path.split('/').pop() ?? file.path}
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height} height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
copyable={false} copyable={false}
maximizable={false} maximizable={false}

View File

@@ -93,6 +93,15 @@
padding: 4px; padding: 4px;
} }
.previewer-ui__action-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
padding-inline: 0;
}
.previewer-ui--headerless { .previewer-ui--headerless {
border-color: transparent; border-color: transparent;
border-radius: 0; border-radius: 0;
@@ -105,17 +114,18 @@
.previewer-ui--expanded { .previewer-ui--expanded {
position: fixed; position: fixed;
inset: 16px; inset: 0;
z-index: 1200; z-index: 1200;
height: auto; height: auto;
border-radius: 20px; border-radius: 0;
border-inline: 0;
box-shadow: box-shadow:
0 24px 64px rgba(15, 23, 42, 0.28), 0 24px 64px rgba(15, 23, 42, 0.28),
0 12px 28px rgba(15, 23, 42, 0.16); 0 12px 28px rgba(15, 23, 42, 0.16);
} }
.previewer-ui--expanded .previewer-ui__body { .previewer-ui--expanded .previewer-ui__body {
height: calc(100vh - 32px - 53px) !important; height: calc(100vh - 53px) !important;
} }
.previewer-ui__language-select { .previewer-ui__language-select {

View File

@@ -1,4 +1,4 @@
import { CopyOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, Empty, Select, message } from 'antd'; import { Button, Empty, Select, message } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -136,6 +136,22 @@ async function copyText(text: string) {
document.body.removeChild(textarea); document.body.removeChild(textarea);
} }
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
}
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 resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'value'>) { function resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'value'>) {
switch (type) { switch (type) {
case 'json': case 'json':
@@ -147,6 +163,45 @@ function resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'valu
} }
} }
function resolveDownloadValue({
type,
value,
downloadValue,
}: Pick<PreviewerUIProps, 'type' | 'value' | 'downloadValue'>) {
if (typeof downloadValue === 'string') {
return downloadValue;
}
if (type === 'image') {
return String(value ?? '');
}
return resolveCopyValue({ type, value });
}
function resolveDownloadFileName({
type,
language,
downloadFileName,
}: Pick<PreviewerUIProps, 'type' | 'language' | 'downloadFileName'>) {
if (downloadFileName?.trim()) {
return downloadFileName.trim();
}
switch (type) {
case 'json':
return 'preview.json';
case 'markdown':
return 'preview.md';
case 'code':
return `preview.${language || 'txt'}`;
case 'image':
return 'preview';
default:
return 'preview.txt';
}
}
function renderContent({ function renderContent({
type, type,
value, value,
@@ -212,6 +267,10 @@ export function PreviewerUI({
value, value,
copyValue, copyValue,
copyable = true, copyable = true,
downloadable = true,
downloadValue,
downloadUrl,
downloadFileName,
maximizable = true, maximizable = true,
language = 'text', language = 'text',
format = 'auto', format = 'auto',
@@ -226,8 +285,11 @@ export function PreviewerUI({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0; const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value }); const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
const canCopy = copyable && resolvedCopyValue.trim().length > 0; const canCopy = copyable && resolvedCopyValue.trim().length > 0;
const shouldShowActions = hasLanguageSelector || canCopy || maximizable || Boolean(toolbar); const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
useEffect(() => { useEffect(() => {
if (!isExpanded || typeof document === 'undefined') { if (!isExpanded || typeof document === 'undefined') {
@@ -271,6 +333,37 @@ export function PreviewerUI({
setIsExpanded((previous) => !previous); setIsExpanded((previous) => !previous);
} }
function handleDownload() {
if (!canDownload) {
return;
}
try {
if (downloadUrl) {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
}
const link = document.createElement('a');
link.href = downloadUrl;
link.download = resolvedDownloadFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const mimeType =
type === 'json'
? 'application/json;charset=utf-8'
: type === 'markdown'
? 'text/markdown;charset=utf-8'
: 'text/plain;charset=utf-8';
downloadBlob(resolvedDownloadValue, resolvedDownloadFileName, mimeType);
}
} catch {
messageApi.error('다운로드에 실패했습니다.');
}
}
const actionContent = ( const actionContent = (
<> <>
{hasLanguageSelector ? ( {hasLanguageSelector ? (
@@ -286,15 +379,27 @@ export function PreviewerUI({
<Button <Button
size="small" size="small"
type="text" type="text"
className="previewer-ui__action-button"
aria-label="복사" aria-label="복사"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => void handleCopy()} onClick={() => void handleCopy()}
/> />
) : null} ) : null}
{canDownload ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={handleDownload}
/>
) : null}
{maximizable ? ( {maximizable ? (
<Button <Button
size="small" size="small"
type="text" type="text"
className="previewer-ui__action-button"
aria-label={isExpanded ? '최대화 해제' : '최대화'} aria-label={isExpanded ? '최대화 해제' : '최대화'}
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />} icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => void toggleFullscreen()} onClick={() => void toggleFullscreen()}

View File

@@ -16,6 +16,10 @@ export type PreviewerUIProps = {
value?: unknown; value?: unknown;
copyValue?: string; copyValue?: string;
copyable?: boolean; copyable?: boolean;
downloadable?: boolean;
downloadValue?: string;
downloadUrl?: string;
downloadFileName?: string;
maximizable?: boolean; maximizable?: boolean;
language?: string; language?: string;
format?: PreviewerFormat; format?: PreviewerFormat;

View File

@@ -2,7 +2,10 @@ import {
ArrowLeftOutlined, ArrowLeftOutlined,
CloseOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
DownloadOutlined,
DownOutlined, DownOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
UpOutlined, UpOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
@@ -2510,6 +2513,7 @@ export function WorklogEvidenceTab({
const [worklogContents, setWorklogContents] = useState<Record<string, string>>({}); const [worklogContents, setWorklogContents] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [previewModalItem, setPreviewModalItem] = useState<EvidenceAttachmentItem | null>(null); const [previewModalItem, setPreviewModalItem] = useState<EvidenceAttachmentItem | null>(null);
const [isPreviewModalExpanded, setIsPreviewModalExpanded] = useState(false);
async function handleCopyText(text: string) { async function handleCopyText(text: string) {
try { try {
@@ -2642,10 +2646,13 @@ export function WorklogEvidenceTab({
open={Boolean(previewModalItem)} open={Boolean(previewModalItem)}
title={previewModalItem?.title ?? 'Preview'} title={previewModalItem?.title ?? 'Preview'}
footer={null} footer={null}
width={1120} width={isPreviewModalExpanded ? 'calc(100vw - 32px)' : 1120}
rootClassName="plan-board-page__evidence-modal" rootClassName={`plan-board-page__evidence-modal${
isPreviewModalExpanded ? ' plan-board-page__evidence-modal--expanded' : ''
}`}
onCancel={() => { onCancel={() => {
setPreviewModalItem(null); setPreviewModalItem(null);
setIsPreviewModalExpanded(false);
}} }}
closeIcon={<CloseOutlined />} closeIcon={<CloseOutlined />}
> >
@@ -2660,18 +2667,30 @@ export function WorklogEvidenceTab({
onClick={() => void handleCopyText(previewModalItem.value)} onClick={() => void handleCopyText(previewModalItem.value)}
/> />
{previewModalItem.linkUrl ? ( {previewModalItem.linkUrl ? (
<Button type="link" href={previewModalItem.linkUrl} target="_blank" rel="noreferrer"> <Button
aria-label="다운로드"
</Button> icon={<DownloadOutlined />}
href={previewModalItem.linkUrl}
target={previewModalItem.kind === 'preview' ? '_blank' : undefined}
rel={previewModalItem.kind === 'preview' ? 'noreferrer' : undefined}
download={previewModalItem.kind === 'preview' ? undefined : true}
/>
) : null} ) : null}
<Button <Button
aria-label={isPreviewModalExpanded ? '최대화 해제' : '최대화'}
icon={isPreviewModalExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => {
setIsPreviewModalExpanded((previous) => !previous);
}}
/>
<Button
aria-label="닫기"
icon={<CloseOutlined />} icon={<CloseOutlined />}
onClick={() => { onClick={() => {
setPreviewModalItem(null); setPreviewModalItem(null);
setIsPreviewModalExpanded(false);
}} }}
> />
</Button>
</Space> </Space>
</Flex> </Flex>
<div className="plan-board-page__evidence-modal-body"> <div className="plan-board-page__evidence-modal-body">

View File

@@ -17,6 +17,13 @@ body,
overflow-x: hidden; 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 { .markdown-preview__image {
display: block; display: block;
width: 100%; width: 100%;
@@ -35,6 +42,7 @@ body {
min-width: 320px; min-width: 320px;
font-family: font-family:
'SUIT Variable', 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; 'SUIT Variable', 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, sans-serif;
color: #182230;
} }
img, img,

View File

@@ -76,6 +76,10 @@ self.addEventListener('notificationclick', (event) => {
const notificationCategory = typeof notificationData.category === 'string' ? notificationData.category.trim() : ''; const notificationCategory = typeof notificationData.category === 'string' ? notificationData.category.trim() : '';
const notificationType = typeof notificationData.type === 'string' ? notificationData.type.trim() : ''; const notificationType = typeof notificationData.type === 'string' ? notificationData.type.trim() : '';
const notificationThreadId = typeof notificationData.threadId === 'string' ? notificationData.threadId.trim() : ''; const notificationThreadId = typeof notificationData.threadId === 'string' ? notificationData.threadId.trim() : '';
const isChatNotification =
notificationCategory === 'chat' ||
notificationType.startsWith('chat') ||
notificationThreadId.startsWith('chat:');
event.notification.close(); event.notification.close();
let targetUrl; let targetUrl;
@@ -98,6 +102,17 @@ self.addEventListener('notificationclick', (event) => {
targetUrl.searchParams.set('topMenu', notificationData.category === 'chat' ? 'chat' : 'plans'); 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')) { if (notificationData.planId && !targetUrl.searchParams.has('planId')) {
targetUrl.searchParams.set('planId', String(notificationData.planId)); targetUrl.searchParams.set('planId', String(notificationData.planId));
} }
@@ -109,11 +124,6 @@ self.addEventListener('notificationclick', (event) => {
event.waitUntil( event.waitUntil(
Promise.all([ Promise.all([
self.registration.getNotifications().then((notifications) => { self.registration.getNotifications().then((notifications) => {
const isChatNotification =
notificationCategory === 'chat' ||
notificationType.startsWith('chat') ||
notificationThreadId.startsWith('chat:');
if (!notificationSessionId || !isChatNotification) { if (!notificationSessionId || !isChatNotification) {
return; return;
} }

View File

@@ -1,5 +1,6 @@
import { cpSync, existsSync, mkdirSync, readdirSync } from 'fs'; 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 { defineConfig, type ResolvedConfig, type ViteDevServer } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'; 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_EMPTY_OUT_DIR = processEnv.VITE_EMPTY_OUT_DIR !== 'false';
const VITE_FILTER_PUBLIC_DIR = processEnv.VITE_FILTER_PUBLIC_DIR === 'true'; 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_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) { function shouldIgnoreDevUpdatePath(watchedPath: string) {
return ( return (
@@ -121,6 +124,13 @@ function copyPublicAssetsExceptCodexChat() {
} }
export default defineConfig({ export default defineConfig({
resolve: {
alias: VITE_DISABLE_PWA
? {
'virtual:pwa-register': resolve(ROOT_DIR, 'src/app/main/pwaRegisterStub.ts'),
}
: undefined,
},
build: { build: {
copyPublicDir: !VITE_FILTER_PUBLIC_DIR, copyPublicDir: !VITE_FILTER_PUBLIC_DIR,
emptyOutDir: VITE_EMPTY_OUT_DIR, emptyOutDir: VITE_EMPTY_OUT_DIR,
@@ -163,44 +173,48 @@ export default defineConfig({
react(), react(),
createDevAppUpdatePlugin(), createDevAppUpdatePlugin(),
copyPublicAssetsExceptCodexChat(), copyPublicAssetsExceptCodexChat(),
VitePWA({ !VITE_DISABLE_PWA &&
strategies: 'injectManifest', VitePWA({
srcDir: 'src', strategies: 'injectManifest',
filename: 'sw.js', srcDir: 'src',
registerType: 'prompt', filename: 'sw.js',
includeAssets: ['favicon.svg', 'apple-touch-icon.svg'], registerType: 'prompt',
injectManifest: { includeAssets: ['favicon.svg', 'apple-touch-icon.svg'],
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'], workbox: {
globIgnores: ['**/.codex_chat/**'], maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, },
}, injectManifest: {
manifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
name: 'AI Code App', globIgnores: ['**/.codex_chat/**'],
short_name: 'AI Code App', maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
description: 'Ant Design 기반 UI 샘플과 문서를 확인하는 AI Code App', },
theme_color: '#165dff', manifest: {
background_color: '#eff5ff', name: 'AI Code App',
display: 'standalone', short_name: 'AI Code App',
lang: 'ko', description: 'Ant Design 기반 UI 샘플과 문서를 확인하는 AI Code App',
scope: '/', theme_color: '#165dff',
start_url: '/', background_color: '#eff5ff',
icons: [ display: 'standalone',
{ lang: 'ko',
src: '/pwa-192x192.svg', scope: '/',
sizes: '192x192', start_url: '/',
type: 'image/svg+xml', icons: [
}, {
{ src: '/pwa-192x192.svg',
src: '/pwa-512x512.svg', sizes: '192x192',
sizes: '512x512', type: 'image/svg+xml',
type: 'image/svg+xml', },
purpose: 'any maskable', {
}, src: '/pwa-512x512.svg',
], sizes: '512x512',
}, type: 'image/svg+xml',
devOptions: { purpose: 'any maskable',
enabled: true, },
}, ],
}), },
], devOptions: {
enabled: true,
},
}),
].filter(Boolean),
}); });