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

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
```
## 확인용 Preview 컨테이너
실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml`을 사용합니다.
```bash
docker compose -f docker-compose.preview.yml up -d --build
```
- 기본 접속 주소: `http://127.0.0.1:4173`
- 소스 코드는 이미지 빌드 시점에 복사되므로, 로컬 파일 변경이 컨테이너에 바로 섞이지 않습니다.
- API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다.
- 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다.
## 테스트용 컨테이너 운영 기준
- 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다.
- 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다.
- `https://test.sm-home.cloud/` 운영 기준은 `화면 / -> 5174 앱 테스트 서버`, `/api/``/ws/chat` -> `127.0.0.1:3100 work-server` 입니다.
- 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다.
- 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다.
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
```bash
docker compose -f docker-compose.preview.yml up -d --build --force-recreate --no-deps preview-app
```
- 저장소 설정에 없는 임시 테스트 컨테이너가 남아 있으면 정리 대상입니다.
## PhotoPrism
루트 `docker-compose.yml`에는 PhotoPrism와 MariaDB 서비스가 포함되어 있습니다.

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 { getAppConfig, upsertAppConfig } from '../services/app-config-service.js';
import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => {
@@ -11,6 +12,44 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/chat-types', async () => {
const chatTypes = await getChatTypesConfig();
return {
ok: true,
chatTypes,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
chatTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes);
return {
ok: true,
chatTypes: savedChatTypes,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};

View File

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

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 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>[] = []) {
filters.forEach((filter) => {
@@ -138,7 +193,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
@@ -150,7 +205,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
return {
ok: true,
table,
rows: inserted,
rows: maskCrudRowSensitiveFields(inserted),
};
});
@@ -197,7 +252,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
@@ -214,7 +269,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage,
mergeChatConversationRequestStatus,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
@@ -26,6 +27,28 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa
);
});
test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
assert.equal(
isVisibleConversationMessage({
id: 1,
author: 'system',
text: '응답을 준비하고 있습니다.',
timestamp: '2026-04-22 10:00:00',
}),
false,
);
assert.equal(
isVisibleConversationMessage({
id: 2,
author: 'system',
text: '[[activity-log]]\n작업을 시작했습니다.',
timestamp: '2026-04-22 10:00:01',
}),
true,
);
});
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
assert.deepEqual(
buildChatConversationRequestPatchFromMessage({

View File

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

View File

@@ -352,6 +352,50 @@ class ChatRuntimeService {
this.emit();
}
clearSession(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
let changed = false;
for (const [requestId, item] of this.runningJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.runningJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
for (const [requestId, item] of this.queuedJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.queuedJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
for (const [requestId, item] of this.archivedJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.archivedJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
if (changed) {
this.emit();
}
}
private buildTerminalLog(status: ChatRuntimeTerminalStatus) {
if (status === 'completed') {
return '실행이 완료되었습니다.';

View File

@@ -150,7 +150,8 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources',
'response.diff',
);
assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
assert.match(rewritten, new RegExp(`\\[\\[preview:${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\]$`, 'm'));
assert.doesNotMatch(rewritten, /diff 리소스 경로:/);
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
});

View File

@@ -22,6 +22,7 @@ import {
updateChatConversationContext,
} from './chat-room-service.js';
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js';
import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
@@ -162,6 +163,7 @@ type ChatSessionState = {
clientId: string | null;
socket: WebSocket | null;
lastSeenAt: number;
isDeleted: boolean;
context: ChatContext | null;
queue: Array<{
requestId: string;
@@ -188,12 +190,17 @@ type ActiveChatExecution = {
};
let activeRuntimeController: ChatRuntimeController | null = null;
let activeChatService: ChatService | null = null;
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
export function getChatRuntimeController() {
return activeRuntimeController;
}
export function getActiveChatService() {
return activeChatService;
}
const SOCKET_PATH = '/ws/chat';
const KST_TIME_ZONE = 'Asia/Seoul';
const STREAM_CAPTURE_LIMIT = 256 * 1024;
@@ -275,8 +282,11 @@ function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId:
try {
const targetUrl = new URL(pageUrl);
targetUrl.pathname = '/chat/live';
targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
targetUrl.searchParams.set('sessionId', sessionId);
targetUrl.searchParams.delete('chatView');
targetUrl.searchParams.delete('runtimeRequestId');
return targetUrl.toString();
} catch {
return fallbackUrl.toString();
@@ -397,6 +407,15 @@ function createRequestId() {
return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) {
const queryToken = url.searchParams.get('accessToken')?.trim();
const headerToken = Array.isArray(request.headers['x-access-token'])
? String(request.headers['x-access-token'][0] ?? '').trim()
: String(request.headers['x-access-token'] ?? '').trim();
return hasErrorLogViewAccessToken(queryToken || headerToken);
}
function hashRequestId(value: string) {
let hash = 0;
@@ -454,13 +473,19 @@ function isSocketOpen(socket: WebSocket | null | undefined) {
return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN);
}
function closeSocketSafely(logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string) {
function closeSocketSafely(
logger: FastifyBaseLogger,
socket: WebSocket | null | undefined,
message: string,
code = 1000,
reason = 'replaced',
) {
if (!socket) {
return;
}
try {
socket.close();
socket.close(code, reason);
} catch (error) {
logger.warn(error, message);
}
@@ -1293,15 +1318,8 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
return output;
}
const lines = ['diff 리소스 경로:'];
if (uniqueUrls.length === 1) {
lines.push(uniqueUrls[0]!);
} else {
lines.push(...uniqueUrls.map((url, index) => `${index + 1}. ${url}`));
}
return `${output}\n\n${lines.join('\n')}`;
const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
return `${output}\n\n${hiddenPreviewTags}`;
}
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
@@ -1452,18 +1470,22 @@ function buildAgenticCodexPrompt(
'응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
'',
'현재 화면 문맥:',
'채팅 유형 문맥(우선 적용):',
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
`- topMenu: ${context?.topMenu ?? '없음'}`,
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'',
isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:',
...(isTemplateRequest
@@ -1627,7 +1649,7 @@ async function runAgenticCodexReply(
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
) {
const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot();
const recentHistory =
@@ -2109,62 +2131,9 @@ async function buildCodexReply(
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
) {
const normalized = input.toLowerCase();
if (isAutomationRegistrationCountRequest(input)) {
return buildAutomationRegistrationCountReply();
}
if (isAutomationRegistrationDefinitionRequest(input)) {
return buildAutomationRegistrationDefinitionReply();
}
if (shouldUseAgenticCodexReply(input)) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
}
const requestsPlanContext =
isWorklogRequest(input) ||
isPlanDetailRequest(input) ||
normalized.includes('preview') ||
normalized.includes('링크') ||
normalized.includes('url') ||
input.includes('변경') ||
input.includes('스크린샷');
const parsedPlanContext = parsePlanContext(context, input);
let snapshot = parsedPlanContext.planId ? await loadPlanSnapshot(parsedPlanContext.planId) : null;
if (!snapshot && parsedPlanContext.workId) {
const planItem = await findPlanItemByWorkId(parsedPlanContext.workId);
snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null;
}
if (!snapshot && parsedPlanContext.previewUrl) {
const planItem = await findPlanItemByPreviewUrl(parsedPlanContext.previewUrl);
snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null;
}
if (!snapshot && requestsPlanContext) {
const latestPlanItem = await findLatestPlanItem();
snapshot = latestPlanItem?.id ? await loadPlanSnapshot(Number(latestPlanItem.id)) : null;
}
const isPlanPage =
context?.topMenu === 'plans' ||
context?.pageId?.startsWith('plans:') ||
isPlanDetailRequest(input);
if (isPlanPage && snapshot) {
return buildPlanReply(context, input, snapshot);
}
if (!shouldUseTemplateMacroReply(context, input)) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
}
return buildGenericReply(context, input, snapshot);
}
export class ChatService {
private readonly wss = new WebSocketServer({ noServer: true });
@@ -2174,6 +2143,7 @@ export class ChatService {
private readonly unsubscribeRuntimeBroadcast: () => void;
constructor(private readonly logger: FastifyBaseLogger) {
activeChatService = this;
activeRuntimeController = {
getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
@@ -2215,6 +2185,9 @@ export class ChatService {
close() {
activeRuntimeController = null;
if (activeChatService === this) {
activeChatService = null;
}
this.unsubscribeRuntimeBroadcast();
for (const execution of activeChatProcessRegistry.values()) {
@@ -2248,6 +2221,7 @@ export class ChatService {
clientId: clientId?.trim() || null,
socket: null,
lastSeenAt: Date.now(),
isDeleted: false,
context: null,
queue: [],
activeRequestCount: 0,
@@ -2282,6 +2256,10 @@ export class ChatService {
}
private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
if (session.isDeleted) {
return Promise.resolve();
}
const nextPersistence = session.messagePersistenceTail
.catch(() => undefined)
.then(() =>
@@ -2319,6 +2297,10 @@ export class ChatService {
skipOfflineNotification?: boolean;
},
) {
if (session.isDeleted) {
return this.createSessionEnvelope(session, message);
}
const envelope = this.createSessionEnvelope(session, message);
this.retainEnvelopeForReplay(session, envelope);
@@ -2739,7 +2721,7 @@ export class ChatService {
}
private replaySessionHistory(session: ChatSessionState, lastEventId: number) {
if (!Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
return;
}
@@ -2751,6 +2733,10 @@ export class ChatService {
}
private async initializeSession(session: ChatSessionState) {
if (session.isDeleted) {
return;
}
await session.messagePersistenceTail.catch(() => undefined);
const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });
@@ -2782,6 +2768,12 @@ export class ChatService {
private async handleConnection(socket: WebSocket, request: IncomingMessage) {
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
const url = new URL(request.url ?? '/', origin);
if (!hasAuthorizedChatSocketAccess(request, url)) {
closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized');
return;
}
const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
const clientId = url.searchParams.get('clientId')?.trim() || null;
const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
@@ -2819,6 +2811,62 @@ export class ChatService {
this.replaySessionHistory(session, lastEventId);
}
async forgetSession(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const session = this.sessions.get(normalizedSessionId);
const runtimeSnapshot = chatRuntimeService.getSnapshot();
const runtimeRequestIds = new Set(
[...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
.filter((item) => item.sessionId === normalizedSessionId)
.map((item) => item.requestId),
);
if (session) {
session.isDeleted = true;
session.queue = [];
session.eventHistory = [];
session.pendingQueueReleaseEventId = null;
session.watchedRuntimeRequestId = null;
session.activeRequestCount = 0;
if (session.socket) {
this.clientStates.delete(session.socket);
closeSocketSafely(this.logger, session.socket, 'failed to close deleted chat websocket session');
session.socket = null;
}
this.sessions.delete(normalizedSessionId);
}
for (const requestId of runtimeRequestIds) {
const detail = chatRuntimeService.getJobDetail(requestId);
if (detail.availableActions.cancel) {
try {
await this.cancelRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
} else if (detail.availableActions.remove) {
try {
await this.removeQueuedRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
}
activeChatProcessRegistry.delete(requestId);
this.cancelledRequestIds.delete(requestId);
}
chatRuntimeService.clearSession(normalizedSessionId);
}
private handleMessage(socket: WebSocket, raw: RawData) {
try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage;

View File

@@ -1,5 +1,6 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -118,6 +119,259 @@ const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
const DEFERRED_RESTART_DELAY_MS = 2_000;
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
const DEFERRED_RESTART_POLL_INTERVAL_MS = 150;
const APP_SOURCE_TARGET_PATHS = [
'src',
'public',
'index.html',
'package.json',
'tsconfig.json',
'tsconfig.app.json',
'vite.config.ts',
'scripts',
] as const;
const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/index.html',
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
'/tmp/ai-code-test-app-dist/assets',
] as const;
async function readLocalBuildTimestamp(targetPath: string) {
try {
const targetStat = await stat(targetPath);
return normalizeDateTimeValue(targetStat.mtime.toISOString());
} catch {
return null;
}
}
async function readContainerBuildTimestamp(definition: ServerDefinition, targetPath: string) {
try {
const { stdout } = await execFileAsync(
'docker',
['exec', definition.containerName, 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`],
{
cwd: definition.commandWorkingDirectory,
timeout: 8000,
maxBuffer: 1024 * 1024,
},
);
return normalizeDateTimeValue(stdout.trim());
} catch (error) {
if (!shouldRetryWithDockerSocket(error)) {
return null;
}
return readContainerBuildTimestampViaSocket(definition, targetPath);
}
}
type SourceChangeInfo = {
changedAt: string;
path: string;
};
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<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) {
return value.trim().replace(/\/+$/, '');
@@ -181,7 +435,27 @@ function shouldRetryWithDockerSocket(error: unknown) {
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
return failure?.code === 127 || /docker CLI not found/i.test(detail);
return failure?.code === 127 || failure?.code === 'ENOENT' || /docker CLI not found|spawn docker ENOENT/i.test(detail);
}
function decodeDockerExecStream(buffer: Buffer) {
let offset = 0;
const chunks: Buffer[] = [];
while (offset + 8 <= buffer.length) {
const frameLength = buffer.readUInt32BE(offset + 4);
const frameStart = offset + 8;
const frameEnd = frameStart + frameLength;
if (frameEnd > buffer.length) {
break;
}
chunks.push(buffer.subarray(frameStart, frameEnd));
offset = frameEnd;
}
return (chunks.length > 0 ? Buffer.concat(chunks) : buffer).toString('utf8');
}
export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) {
@@ -794,6 +1068,19 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null),
};
} 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);
}
}
@@ -935,8 +1222,61 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
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> {
if (definition.key !== 'work-server') {
const appBuildInfo = await inspectAppContainerBuild(definition);
if (appBuildInfo) {
return appBuildInfo;
}
return {
runningVersion: 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"
/>
<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="apple-touch-icon" href="/apple-touch-icon.svg" />
<title>AI Code App</title>

View File

@@ -40,7 +40,7 @@
"plan:codex:once": "node scripts/run-plan-codex-once.mjs",
"server-command:runner": "node scripts/run-server-command-runner.mjs",
"build:app": "tsc -b && vite build --outDir app-dist",
"build:test-app": "VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist",
"build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist",
"build:lib": "tsc -p tsconfig.lib.json",
"build": "npm run build:lib && npm run build:app",
"prepublishOnly": "npm run build:lib",

View File

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

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 `${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: {

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,10 @@
.app-chat-panel__preview-modal.ant-modal {
z-index: 1400;
max-width: 100vw;
margin: 0;
top: 0;
padding-bottom: 0;
}
.app-chat-panel__preview-modal.ant-modal .ant-modal-mask {
@@ -99,6 +103,15 @@
height: 100%;
}
.app-chat-panel__stack--chat {
flex-direction: row;
align-items: stretch;
flex: 1 1 auto;
gap: 0;
width: 100%;
min-width: 0;
}
.app-chat-panel__conversation-shell {
position: relative;
display: flex;
@@ -116,8 +129,10 @@
.app-chat-panel__conversation-list {
display: flex;
flex-direction: column;
width: 220px;
min-width: 220px;
flex: 0 0 280px;
width: 280px;
min-width: 280px;
max-width: 280px;
min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.72);
@@ -136,6 +151,19 @@
padding: 8px 8px 0;
}
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper,
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:hover,
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus,
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus-within {
border-color: rgba(148, 163, 184, 0.12);
box-shadow: none;
}
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper {
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
}
.app-chat-panel__conversation-list-body {
display: flex;
flex: 1;
@@ -220,7 +248,7 @@
gap: 6px;
min-width: 0;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.14);
border: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(255, 255, 255, 0.82);
border-radius: 14px;
transition:
@@ -231,13 +259,13 @@
}
.app-chat-panel__conversation-item--active {
border-color: rgba(59, 130, 246, 0.35);
border-color: rgba(59, 130, 246, 0.16);
background: rgba(239, 246, 255, 0.95);
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08);
}
.app-chat-panel__conversation-item--processing {
border-color: rgba(245, 158, 11, 0.34);
border-color: rgba(245, 158, 11, 0.14);
background:
linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%),
#fff;
@@ -247,7 +275,7 @@
}
.app-chat-panel__conversation-item--unread {
border-color: rgba(37, 99, 235, 0.7);
border-color: rgba(37, 99, 235, 0.18);
background:
linear-gradient(90deg, rgba(147, 197, 253, 0.98), rgba(219, 234, 254, 0.98) 22%, rgba(239, 246, 255, 0.98) 46%, rgba(255, 255, 255, 0.99) 68%),
#fff;
@@ -257,7 +285,7 @@
}
.app-chat-panel__conversation-item--unread-section {
border-color: rgba(37, 99, 235, 0.82);
border-color: rgba(37, 99, 235, 0.2);
background:
linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%),
#fff;
@@ -296,7 +324,7 @@
}
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread {
border-color: rgba(29, 78, 216, 0.78);
border-color: rgba(29, 78, 216, 0.2);
background:
linear-gradient(90deg, rgba(147, 197, 253, 1), rgba(191, 219, 254, 0.99) 24%, rgba(219, 234, 254, 0.99) 46%, rgba(239, 246, 255, 1) 70%),
#fff;
@@ -528,9 +556,10 @@
.app-chat-panel__conversation-main {
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
width: 100%;
width: auto;
max-width: 100%;
min-width: 0;
min-height: 0;
position: relative;
@@ -793,7 +822,7 @@
.app-chat-panel__title-group {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
min-width: 0;
max-width: 100%;
@@ -1206,6 +1235,14 @@
width: 100%;
}
.app-chat-message-stack--artifact-only {
gap: 0;
}
.app-chat-message-stack--artifact-only .app-chat-message-stack__previews {
gap: 10px;
}
.app-chat-message--codex {
--app-chat-message-fade-end: rgba(248, 251, 255, 0.96);
margin-left: 8px;
@@ -1325,11 +1362,37 @@
max-width: 100%;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.app-chat-message__block {
white-space: pre-wrap;
}
.app-chat-message__block + .app-chat-message__block {
margin-top: 4px;
}
.app-chat-message__block--spacer {
min-height: calc(1.45em * 0.7);
}
.app-chat-message__block--image {
white-space: normal;
}
.app-chat-message__inline-image {
display: block;
width: min(100%, 560px);
margin-top: 2px;
}
.app-chat-message__body a {
text-decoration: underline;
text-underline-offset: 2px;
}
.app-chat-message__body--collapsed {
position: relative;
max-height: calc(1.45em * 6);
@@ -1370,7 +1433,7 @@
gap: 8px;
width: 100%;
max-width: none;
padding: 8px 10px 10px;
padding: 8px 0 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
@@ -1388,13 +1451,18 @@
.app-chat-message-stack--codex .app-chat-preview-card,
.app-chat-message-stack--system .app-chat-preview-card {
margin-left: 8px;
margin-right: 24px;
margin-left: 0;
margin-right: 0;
}
.app-chat-message-stack--user .app-chat-preview-card {
margin-left: 24px;
margin-right: 8px;
margin-left: 0;
margin-right: 0;
}
.app-chat-message-stack--artifact-only .app-chat-preview-card {
margin-left: 0;
margin-right: 0;
}
.app-chat-preview-card__header {
@@ -1405,6 +1473,13 @@
padding: 8px 10px;
}
.app-chat-preview-card__actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.app-chat-preview-card--collapsed .app-chat-preview-card__header {
border: 1px solid rgba(148, 163, 184, 0.22);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
@@ -1429,6 +1504,8 @@
margin-top: 1px;
color: #475569;
background: rgba(226, 232, 240, 0.9);
border-radius: 999px;
font-size: 12px;
}
.app-chat-preview-card__titles {
@@ -1440,12 +1517,15 @@
overflow: hidden;
}
.app-chat-preview-card__label,
.app-chat-preview-card__kind,
.app-chat-preview-card__label.ant-typography,
.app-chat-preview-card__kind.ant-typography {
margin: 0;
max-width: 100%;
}
.app-chat-preview-card__label,
.app-chat-preview-card__label.ant-typography {
font-size: 12px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -1455,10 +1535,12 @@
text-overflow: ellipsis;
}
.app-chat-preview-card__kind,
.app-chat-preview-card__kind.ant-typography {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1471,6 +1553,13 @@
flex: 0 0 auto;
}
.app-chat-preview-card__action.ant-btn {
height: 22px;
min-width: 22px;
padding: 0;
color: #475569;
}
@media (max-width: 1180px) {
.app-chat-panel {
height: 100%;
@@ -1496,6 +1585,7 @@
}
.app-chat-panel__conversation-shell,
.app-chat-panel__stack--chat,
.app-chat-panel__conversation-main,
.app-chat-panel__conversation-view,
.app-chat-panel__conversation-view-inner,
@@ -1527,9 +1617,15 @@
.app-chat-message-stack--codex .app-chat-preview-card,
.app-chat-message-stack--system .app-chat-preview-card,
.app-chat-message-stack--user .app-chat-preview-card {
margin-left: 4px;
margin-right: 4px;
max-width: calc(100% - 8px);
margin-left: 0;
margin-right: 0;
max-width: 100%;
}
.app-chat-message-stack--artifact-only .app-chat-preview-card {
margin-left: 0;
margin-right: 0;
max-width: 100%;
}
.app-chat-panel__composer-queue {
@@ -1537,6 +1633,21 @@
}
}
@media (min-width: 1181px) and (max-width: 1366px) {
.app-chat-panel__conversation-list {
flex: 0 0 clamp(208px, 19vw, 240px);
width: clamp(208px, 19vw, 240px);
min-width: clamp(208px, 19vw, 240px);
max-width: clamp(208px, 19vw, 240px);
}
.app-chat-panel__conversation-main,
.app-chat-panel__conversation-view,
.app-chat-panel__conversation-view-inner {
width: auto;
}
}
.app-chat-preview-card__body {
display: flex;
min-height: 0;
@@ -1545,6 +1656,84 @@
width: 100%;
}
.app-chat-preview-card--fullscreen {
position: fixed;
inset: 0;
z-index: 1400;
width: 100vw;
min-width: 100vw;
max-width: 100vw;
height: 100vh;
max-height: 100vh;
margin: 0 !important;
gap: 0;
padding: 0;
border: 0;
border-radius: 0;
background: #f8fafc;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.26);
}
.app-chat-preview-card--fullscreen .app-chat-preview-card__header {
position: sticky;
top: 0;
z-index: 1;
padding: 12px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96));
}
.app-chat-preview-card--fullscreen .app-chat-preview-card__body {
flex: 1 1 auto;
min-height: 0;
width: 100vw;
max-width: 100vw;
padding-top: 0;
border-top: 0;
overflow: hidden;
}
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich {
width: 100vw;
max-width: 100vw;
}
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich,
.app-chat-preview-card--fullscreen .codex-diff-previewer,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body,
.app-chat-preview-card--fullscreen .previewer-ui,
.app-chat-preview-card--fullscreen .previewer-ui__editor,
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
height: 100%;
width: 100%;
max-width: none;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list {
gap: 0;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section {
border-width: 0 0 1px;
border-radius: 0;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toggle {
padding-inline: 16px 88px;
}
.app-chat-preview-card--fullscreen .previewer-ui__editor {
border-width: 0;
border-radius: 0;
}
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
max-height: none;
padding-inline: 0;
}
.app-chat-panel__preview-rich {
width: 100%;
min-width: 0;
@@ -1556,6 +1745,13 @@
width: 100%;
}
.app-chat-panel__preview-rich .codex-diff-previewer,
.app-chat-panel__preview-rich .codex-diff-previewer__diff-body,
.app-chat-panel__preview-rich .previewer-ui,
.app-chat-panel__preview-rich .previewer-ui__body {
width: 100%;
}
.app-chat-panel__preview-rich .previewer-ui__editor {
border-color: rgba(15, 23, 42, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -1583,6 +1779,10 @@
color: #e2e8f0;
}
.app-chat-panel__preview-rich .codex-diff-previewer__diff-list--expand-all .codex-diff-previewer__diff-section {
border-radius: 0;
}
.app-chat-panel__preview-rich--markdown {
padding: 4px 2px 0;
}
@@ -1762,7 +1962,7 @@
font-size: 13px;
line-height: 1.4;
min-height: 88px;
padding: 8px 14px 12px;
padding: 8px 76px 16px 14px;
}
.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea {
@@ -1806,6 +2006,37 @@
display: none;
}
.app-chat-panel__composer-clear.ant-btn {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
height: 28px;
padding: 0 10px;
border-radius: 999px;
color: rgba(71, 85, 105, 0.88);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.24);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
opacity: 0;
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
transform: translateY(4px);
}
.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.app-chat-panel__composer-clear.ant-btn:disabled {
opacity: 0;
pointer-events: none;
}
.app-chat-panel__composer-attachment-strip {
display: flex;
flex-wrap: wrap;
@@ -1943,6 +2174,7 @@
.app-chat-panel__preview-stage > * {
width: 100%;
height: 100%;
}
.app-chat-panel__preview-loading {
@@ -1973,6 +2205,10 @@
pointer-events: none;
}
.app-chat-panel__conversation-view-inner.is-busy {
user-select: auto;
}
.app-chat-panel__conversation-loading {
position: absolute;
inset: 0;
@@ -1994,6 +2230,34 @@
text-align: center;
}
.app-chat-panel__busy-overlay {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 28px 24px;
border-radius: 24px;
background:
radial-gradient(circle at top, rgba(147, 197, 253, 0.26), transparent 48%),
linear-gradient(180deg, rgba(248, 250, 252, 0.64), rgba(241, 245, 249, 0.74));
backdrop-filter: blur(4px);
text-align: center;
}
.app-chat-panel__busy-overlay strong {
color: #0f172a;
}
.app-chat-panel__busy-overlay span {
color: #475569;
font-size: 12px;
}
.app-chat-panel__preview-image,
.app-chat-panel__preview-video,
.app-chat-panel__preview-frame {
@@ -2034,13 +2298,50 @@
}
.app-chat-panel__preview-modal .ant-modal-body {
padding-top: 12px;
padding: 12px 0 0;
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.app-chat-panel__preview-modal {
z-index: 1600;
}
.app-chat-panel__preview-modal .ant-modal-content {
display: flex;
flex-direction: column;
min-height: 0;
height: 100dvh;
max-height: 100dvh;
padding: 0;
border-radius: 0;
}
.app-chat-panel__preview-modal .ant-modal-header {
margin-bottom: 0;
padding: 16px 20px 12px;
border-radius: 0;
}
.app-chat-panel__preview-modal .ant-modal-title {
padding-right: 40px;
}
.app-chat-panel__preview-modal .ant-modal-footer {
margin-top: 0;
padding: 0 20px 16px;
border-top: 0;
}
.app-chat-panel__preview-stage--modal {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.app-chat-panel__delete-confirm-modal {
z-index: 1700 !important;
}
@@ -2052,13 +2353,59 @@
.app-chat-panel__preview-modal-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
gap: 0;
min-height: 0;
overflow: hidden;
}
.app-chat-panel__preview-modal-meta {
display: flex;
justify-content: flex-start;
padding: 0 20px 12px;
}
.app-chat-panel__preview-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-rich,
.app-chat-panel__preview-modal .previewer-ui,
.app-chat-panel__preview-modal .previewer-ui__editor,
.app-chat-panel__preview-modal .previewer-ui__editor-body,
.app-chat-panel__preview-modal .codex-diff-previewer,
.app-chat-panel__preview-modal .codex-diff-previewer__diff-list,
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
.app-chat-panel__preview-modal .codex-diff-previewer__diff-body {
height: 100%;
width: 100%;
max-width: none;
}
.app-chat-panel__preview-modal .previewer-ui__editor,
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
.app-chat-panel__preview-modal .app-chat-panel__preview-image,
.app-chat-panel__preview-modal .app-chat-panel__preview-video,
.app-chat-panel__preview-modal .app-chat-panel__preview-frame {
border-left-width: 0;
border-right-width: 0;
border-radius: 0;
}
.app-chat-panel__preview-modal .previewer-ui__editor-body {
max-height: none;
padding-inline: 0;
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal-footer {
justify-content: flex-end;
}
}
.app-chat-panel__connection-dot--connecting {
@@ -2429,6 +2776,14 @@
}
@media (max-width: 768px) {
.app-chat-panel__conversation-list {
flex: 1 1 100%;
width: 100%;
min-width: 100%;
max-width: 100%;
border-right: 0;
}
.app-chat-runtime {
overflow: hidden;
}
@@ -2560,6 +2915,63 @@
height: 100%;
}
.chat-v2__pane {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
overflow: hidden;
}
.chat-v2__pane--list {
flex: 0 0 320px;
max-width: 320px;
}
.chat-v2__pane--room,
.chat-v2__pane--runtime,
.chat-v2__pane--errors {
flex: 1 1 auto;
}
.chat-v2__pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
background: rgba(248, 250, 252, 0.96);
}
.chat-v2__pane > .ant-input-search,
.chat-v2__pane > .ant-input-affix-wrapper,
.chat-v2__pane > .ant-input-group-wrapper {
margin: 12px 16px 0;
}
.chat-v2__state {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
text-align: center;
}
.chat-v2__conversation-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 10px 10px;
}
.chat-v2__conversation-list .ant-list-item {
padding: 0;
border-block-end: 0;
@@ -2604,6 +3016,13 @@
font-size: 13px;
}
@media (max-width: 1180px) {
.chat-v2__pane--list {
flex-basis: auto;
max-width: none;
}
}
@media (min-width: 1181px) {
.app-chat-panel {
background:
@@ -2640,18 +3059,19 @@
}
.app-chat-panel__conversation-item {
border-color: rgba(148, 163, 184, 0.14);
border-color: transparent;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
}
.app-chat-panel__conversation-item--active {
border-color: rgba(100, 116, 139, 0.26);
border-color: transparent;
background: rgba(248, 250, 252, 0.98);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
}
.app-chat-panel__conversation-item--unread {
border-color: rgba(148, 163, 184, 0.28);
border-color: transparent;
background:
linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%),
#fff;
@@ -2661,7 +3081,7 @@
}
.app-chat-panel__conversation-item--unread-section {
border-color: rgba(148, 163, 184, 0.32);
border-color: transparent;
background:
linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%),
#fff;
@@ -2679,7 +3099,7 @@
}
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread {
border-color: rgba(100, 116, 139, 0.34);
border-color: transparent;
background:
linear-gradient(90deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 0.99) 34%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%),
#fff;
@@ -2689,7 +3109,7 @@
}
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section {
border-color: rgba(100, 116, 139, 0.38);
border-color: transparent;
background:
linear-gradient(135deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 1) 36%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%),
#fff;

View File

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

View File

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

View File

@@ -214,6 +214,24 @@
background: #2563eb;
}
.app-header__server-version-indicator {
display: inline-flex;
width: 12px;
height: 12px;
border: 2px solid #ffffff;
border-radius: 999px;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.08);
}
.app-header__server-version-indicator--latest {
color: #2563eb;
background: #2563eb;
}
.app-header__server-version-indicator--stale {
background: #dc2626;
}
.app-header__settings-label {
font-size: 14px;
font-weight: 600;
@@ -344,6 +362,15 @@
background: rgba(255, 255, 255, 0.98);
}
.app-sider--mobile-inline.ant-layout-sider {
width: 100% !important;
max-width: 100%;
flex: 0 0 auto !important;
border-right: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(255, 255, 255, 0.92);
}
.app-sider__inner {
display: flex;
flex-direction: column;
@@ -680,6 +707,11 @@
height: calc(100vh - 52px);
}
.app-sider--mobile-inline.ant-layout-sider {
position: static;
height: auto;
}
.app-main-content.ant-layout-content {
padding: 0;
min-height: calc(100dvh - 52px);

View File

@@ -1,4 +1,5 @@
import { Layout, Menu, Space, Tag, Typography } from 'antd';
import type { ItemType } from 'antd/es/menu/interface';
import type { MainSidebarProps } from './types';
const { Sider } = Layout;
@@ -9,6 +10,7 @@ export function MainSidebar({
hasAccess,
sidebarCollapsed,
isMobileViewport,
mobileInline = false,
openKeys: controlledOpenKeys,
apiMenuItems,
docsMenuItems,
@@ -30,64 +32,7 @@ export function MainSidebar({
onSelectChatMenu,
onSelectPlayMenu,
}: MainSidebarProps) {
const effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu;
const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis';
const visibleOpenKeys = sidebarCollapsed
? []
: controlledOpenKeys.length
? controlledOpenKeys
: isDocsGroup
? !hasAccess
? ['docs-group']
: ['docs-group', 'api-group']
: effectiveTopMenu === 'play'
? ['play-group', 'play-layout-group']
: ['plan-group', 'codex-live-group', 'app-log-group', 'chat-manage-group'];
const selectedKeys =
effectiveTopMenu === 'docs'
? [selectedDocsMenu]
: effectiveTopMenu === 'apis'
? [selectedApiMenu]
: effectiveTopMenu === 'plans'
? [selectedPlanMenu]
: effectiveTopMenu === 'play'
? [selectedPlayMenu]
: [selectedChatMenu];
const sidebarItems =
isDocsGroup
? !hasAccess
? [...(docsMenuItems ?? [])]
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
: effectiveTopMenu === 'play'
? [...(playMenuItems ?? [])]
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
return (
<Sider
width={isMobileViewport ? '100%' : 260}
collapsed={sidebarCollapsed}
collapsedWidth={isMobileViewport ? 0 : 72}
className={isMobileViewport ? 'app-sider app-sider--mobile' : 'app-sider'}
theme="light"
>
<div className="app-sider__inner">
<Space direction="vertical" size={8} className="app-sider__intro">
<Tag color={introColor}>{introTag}</Tag>
{!sidebarCollapsed ? (
<Text type="secondary">{introDescription}</Text>
) : null}
</Space>
<Menu
mode="inline"
inlineCollapsed={sidebarCollapsed}
selectedKeys={selectedKeys}
openKeys={visibleOpenKeys}
items={sidebarItems}
onOpenChange={(keys) => {
onOpenKeysChange(keys as string[]);
}}
onClick={({ key, keyPath }) => {
const handleMenuRouteSelection = (key: string, keyPath: string[]) => {
if (keyPath.includes('docs-group')) {
onSelectDocsMenu(key);
return;
@@ -111,6 +56,98 @@ export function MainSidebar({
if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) {
onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']);
}
};
const collectItemKeys = (items: ItemType[] | undefined): string[] =>
(items ?? []).flatMap((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) {
return [];
}
const itemKey = typeof item.key === 'string' ? item.key : '';
const childKeys = 'children' in item ? collectItemKeys(item.children as ItemType[] | undefined) : [];
return itemKey ? [itemKey, ...childKeys] : childKeys;
});
const effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu;
const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis';
const visibleOpenKeys = sidebarCollapsed ? [] : controlledOpenKeys;
const selectedKeys =
effectiveTopMenu === 'docs'
? [selectedDocsMenu]
: effectiveTopMenu === 'apis'
? [selectedApiMenu]
: effectiveTopMenu === 'plans'
? [selectedPlanMenu]
: effectiveTopMenu === 'play'
? [selectedPlayMenu]
: [selectedChatMenu];
const sidebarItems =
isDocsGroup
? !hasAccess
? [...(docsMenuItems ?? [])]
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
: effectiveTopMenu === 'play'
? [...(playMenuItems ?? [])]
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
const rootKeys = sidebarItems.flatMap((item) =>
item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [],
);
const childKeyMap = new Map(
sidebarItems.flatMap((item) => {
if (!item || typeof item !== 'object' || !('key' in item) || typeof item.key !== 'string') {
return [];
}
return [[item.key, new Set(collectItemKeys('children' in item ? (item.children as ItemType[] | undefined) : undefined))]] as const;
}),
);
const isMobileOverlay = isMobileViewport && !mobileInline;
return (
<Sider
width={isMobileViewport ? '100%' : 260}
collapsed={sidebarCollapsed}
collapsedWidth={isMobileOverlay ? 0 : 72}
className={
isMobileOverlay ? 'app-sider app-sider--mobile' : mobileInline ? 'app-sider app-sider--mobile-inline' : 'app-sider'
}
theme="light"
>
<div className="app-sider__inner">
<Space direction="vertical" size={8} className="app-sider__intro">
<Tag color={introColor}>{introTag}</Tag>
{!sidebarCollapsed ? (
<Text type="secondary">{introDescription}</Text>
) : null}
</Space>
<Menu
mode="inline"
triggerSubMenuAction={isMobileViewport ? 'click' : 'hover'}
inlineCollapsed={sidebarCollapsed}
selectedKeys={selectedKeys}
openKeys={visibleOpenKeys}
items={sidebarItems}
onOpenChange={(keys) => {
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 }) => {
handleMenuRouteSelection(String(key), keyPath as string[]);
}}
onSelect={({ key, keyPath }) => {
handleMenuRouteSelection(String(key), keyPath as string[]);
}}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
export const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
const APP_CONFIG_EVENT = 'work-server:app-config';
const APP_CONFIG_API_PATH = '/app-config';
const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';

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 AppUpdateSnapshot = {
@@ -342,6 +340,10 @@ export function initializeAppUpdate() {
return;
}
emit();
void import('virtual:pwa-register')
.then(({ registerSW }) => {
serviceWorkerUpdater = registerSW({
immediate: true,
onNeedRefresh() {
@@ -372,8 +374,16 @@ export function initializeAppUpdate() {
emit();
},
});
})
.catch(() => {
snapshot = {
supported: isAppUpdateSupported(),
status: isAppUpdateSupported() ? 'ready' : 'idle',
progressPercent: null,
currentTaskLabel: null,
};
emit();
});
}
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';
@@ -21,8 +22,12 @@ export type ChatTypeInput = {
enabled?: boolean;
};
const CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
const CHAT_TYPES_API_PATH = '/chat-types';
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
guest: '게스트',
@@ -33,11 +38,12 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'general-request',
name: '일반 요청',
description: '일반 Codex Live 요청입니다. 현재 로컬 main 작업본 기준으로 바로 확인하고 필요 시 소스를 수정합니다.',
description:
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
isTemplate: false,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-20T00:00:00.000Z',
updatedAt: '2026-04-21T00:00:00.000Z',
},
{
id: 'api-request-template',
@@ -54,6 +60,10 @@ function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function buildChatTypeNameKey(value: string | null | undefined) {
return normalizeText(value).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function normalizePermissions(permissions: ChatPermissionRole[] | null | undefined): ChatPermissionRole[] {
const nextPermissions = Array.from(
new Set(
@@ -87,75 +97,216 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
};
}
function ensureDefaultChatTypes(chatTypes: ChatTypeRecord[]) {
const defaultsById = new Map(DEFAULT_CHAT_TYPES.map((item) => [item.id, item]));
const merged = chatTypes.map((item) => {
const defaultItem = defaultsById.get(item.id);
if (!defaultItem) {
return item;
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
}
const storedUpdatedAt = Date.parse(item.updatedAt);
const defaultUpdatedAt = Date.parse(defaultItem.updatedAt);
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(storedUpdatedAt) && Number.isFinite(defaultUpdatedAt) && storedUpdatedAt >= defaultUpdatedAt) {
return item;
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
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'));
return 0;
}
export function loadChatTypes() {
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') {
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 {
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) {
return DEFAULT_CHAT_TYPES;
return null;
}
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
if (!Array.isArray(parsed)) {
return DEFAULT_CHAT_TYPES;
return null;
}
const normalized = parsed
.map((item) => normalizeChatType(item))
.filter((item): item is ChatTypeRecord => Boolean(item));
const resolved = normalized.length > 0 ? ensureDefaultChatTypes(normalized) : DEFAULT_CHAT_TYPES;
if (JSON.stringify(resolved) !== JSON.stringify(normalized)) {
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(resolved));
}
return resolved;
const deletedIds = readLegacyDeletedChatTypeIds();
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
return normalized;
} catch {
return DEFAULT_CHAT_TYPES;
return null;
}
}
export function saveChatTypes(chatTypes: ChatTypeRecord[]) {
function clearLegacyChatTypeStorage() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(chatTypes));
window.dispatchEvent(new CustomEvent(CHAT_TYPE_SYNC_EVENT));
window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
}
async function fetchChatTypesFromServer() {
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<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) {
@@ -170,13 +321,25 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
});
if (!nextRecord) {
return chatTypes;
return sanitizeChatTypes(chatTypes);
}
const nextChatTypes = chatTypes.filter((item) => item.id !== nextRecord.id);
const nextSemanticKey = getChatTypeSemanticKey(nextRecord);
const nextChatTypes = chatTypes.filter(
(item) => item.id !== nextRecord.id && getChatTypeSemanticKey(item) !== nextSemanticKey,
);
nextChatTypes.push(nextRecord);
nextChatTypes.sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
return nextChatTypes;
return sanitizeChatTypes(nextChatTypes);
}
export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string) {
const normalizedId = normalizeText(chatTypeId);
if (!normalizedId) {
return sanitizeChatTypes(chatTypes);
}
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
}
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
@@ -188,31 +351,68 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
}
export function useChatTypeRegistry() {
const [chatTypes, setChatTypes] = useState<ChatTypeRecord[]>(() => loadChatTypes());
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
isMountedRef.current = true;
const syncChatTypes = async () => {
setIsLoading(true);
setErrorMessage('');
try {
const serverChatTypes = await fetchChatTypesFromServer();
let resolvedChatTypes = serverChatTypes;
if (resolvedChatTypes == null) {
const legacyChatTypes = readLegacyChatTypes();
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
}
const syncChatTypes = () => {
setChatTypes(loadChatTypes());
if (isMountedRef.current) {
setChatTypesState(resolvedChatTypes);
}
} catch (error) {
if (isMountedRef.current) {
setChatTypesState(DEFAULT_CHAT_TYPES);
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
window.addEventListener('storage', syncChatTypes);
window.addEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
void syncChatTypes();
const handleSync = () => {
void syncChatTypes();
};
window.addEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
return () => {
window.removeEventListener('storage', syncChatTypes);
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
isMountedRef.current = false;
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
};
}, []);
return {
chatTypes,
setChatTypes: (nextChatTypes: ChatTypeRecord[]) => {
saveChatTypes(nextChatTypes);
setChatTypes(nextChatTypes);
isLoading,
errorMessage,
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
const resolved = await saveChatTypesToServer(nextChatTypes);
if (isMountedRef.current) {
setChatTypesState(resolved);
setErrorMessage('');
}
return resolved;
},
};
}

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';
const { Text } = Typography;
@@ -12,6 +33,460 @@ type ConversationRoomPaneProps = {
errorMessage: string;
};
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
type MessageRenderPayload = {
visibleText: string;
diffBlocks: string[];
};
function formatChatTimestamp(timestamp: string) {
const normalized = String(timestamp ?? '').trim();
if (!normalized) {
return '';
}
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return normalized;
}
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.format(parsed)
.replace(',', '');
}
function classifyInlinePreviewKind(url: string): ChatPreviewKind | 'file' {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
return 'file';
}
function buildInlinePreviewLabel(url: string) {
try {
const parsed = new URL(url);
return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname;
} catch {
return url;
}
}
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
return;
}
const blob = new Blob([content], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
const seen = new Set<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({
sessionId,
messages,
@@ -20,6 +495,30 @@ export function ConversationRoomPane({
loadingLabel,
errorMessage,
}: 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) {
return (
<section className="chat-v2__pane chat-v2__pane--room">
@@ -69,15 +568,122 @@ export function ConversationRoomPane({
<Empty description="메시지가 없습니다." />
</div>
) : (
messages.map((message) => (
<article key={`${message.id}-${message.timestamp}`} className={`chat-v2__message chat-v2__message--${message.author}`}>
<div className="chat-v2__message-meta">
<Text strong>{message.author}</Text>
<Text type="secondary">{message.timestamp}</Text>
messages.map((message) => {
const canCollapseMessage = isLikelyCollapsibleMessage(message.text);
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
.filter(Boolean)
.join(' ');
return (
<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>
<div className="chat-v2__message-body">{message.text}</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>
</section>

View File

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

View File

@@ -1,9 +1,6 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import {
CHAT_CONVERSATIONS_UPDATED_EVENT,
readChatConversationsUpdatedEvent,
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
@@ -19,6 +16,33 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<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({
requestedSessionId,
}: UseConversationListDataOptions): UseConversationListDataResult {
@@ -31,9 +55,11 @@ export function useConversationListData({
try {
const items = await chatGateway.listConversations();
setConversationItems(items);
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch {
setConversationItems([]);
setConversationItems((previous) => previous);
} finally {
setIsConversationListLoading(false);
}
@@ -46,12 +72,14 @@ export function useConversationListData({
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems(items);
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems([]);
setConversationItems((previous) => previous);
}
})
.finally(() => {
@@ -67,63 +95,6 @@ export function useConversationListData({
};
}, []);
useEffect(() => {
if (!requestedSessionId || isConversationListLoading) {
return;
}
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
return;
}
let isCancelled = false;
const loadRequestedConversation = async () => {
try {
const response = await chatGateway.getConversationDetail(requestedSessionId);
if (isCancelled || response.item.sessionId !== requestedSessionId) {
return;
}
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
} catch {
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
}
};
void loadRequestedConversation();
return () => {
isCancelled = true;
};
}, [conversationItems, isConversationListLoading, requestedSessionId]);
useEffect(() => {
const handleConversationsUpdated = (event: Event) => {
const detail = readChatConversationsUpdatedEvent(event);
if (!detail) {
return;
}
setConversationItems(detail.items);
};
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
return () => {
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
};
}, []);
return {
conversationItems,
setConversationItems,

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

View File

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

View File

@@ -132,7 +132,16 @@ export function useConversationViewportController({
setShowScrollToBottom(!isNearBottom);
}, [viewportRef]);
const captureViewportRestoreSnapshot = useCallback(() => {
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
if (options?.forceStickToBottom) {
viewportRestoreSnapshotRef.current = {
shouldStickToBottom: true,
offsetFromBottom: 0,
};
shouldStickToBottomRef.current = true;
return;
}
const viewport = viewportRef.current;
if (!viewport) {

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
import {
CodeOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
ExclamationCircleOutlined,
LinkOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
@@ -14,11 +17,25 @@ import {
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Input, Select, Spin } from 'antd';
import { Alert, Button, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type RefObject, type TouchEvent } from 'react';
import { ChatPreviewBody } from './ChatPreviewBody';
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type ReactNode,
type RefObject,
type TouchEvent,
} from 'react';
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
@@ -44,6 +61,7 @@ type ChatTypeOption = {
type PreviewOption = {
id: string;
label: string;
url: string;
kind: string;
};
@@ -53,7 +71,7 @@ type QueuedRequestOption = {
text: string;
};
type InlinePreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
type InlinePreviewKind = ChatPreviewKind;
type InlinePreviewTarget = {
url: string;
@@ -66,9 +84,17 @@ type PreviewFetchError = Error & {
};
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
type MessageRenderPayload = {
visibleText: string;
diffBlocks: string[];
};
function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
@@ -89,6 +115,10 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
@@ -104,6 +134,22 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
return 'file';
}
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
return;
}
const blob = new Blob([content], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function buildInlinePreviewLabel(url: string) {
try {
const parsed = new URL(url);
@@ -143,7 +189,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
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 targets: InlinePreviewTarget[] = [];
@@ -170,6 +216,81 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
return targets;
}
function renderMessageInlineParts(line: string): ReactNode[] {
const renderedParts: ReactNode[] = [];
let cursor = 0;
for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
const [fullMatch, label, rawHref] = match;
const start = match.index ?? 0;
if (start > cursor) {
renderedParts.push(line.slice(cursor, start));
}
const href = normalizeInlinePreviewUrl(rawHref.trim());
renderedParts.push(
<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) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
@@ -289,10 +410,14 @@ function getRequestDetailText(request: ChatConversationRequest | undefined) {
function InlineMessagePreview({
target,
isExpanded,
hasModalPreview,
onOpenModalPreview,
onToggle,
}: {
target: InlinePreviewTarget;
isExpanded: boolean;
hasModalPreview: boolean;
onOpenModalPreview: () => void;
onToggle: () => void;
}) {
const [textPreview, setTextPreview] = useState('');
@@ -347,18 +472,68 @@ function InlineMessagePreview({
};
}, [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 (
<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">
<LinkOutlined />
{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">{target.kind}</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={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"
@@ -368,6 +543,7 @@ function InlineMessagePreview({
onClick={onToggle}
/>
</div>
</div>
{isExpanded ? (
<div className="app-chat-preview-card__body">
@@ -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 = {
viewportRef: RefObject<HTMLDivElement | null>;
composerRef: RefObject<TextAreaRef | null>;
@@ -418,9 +688,10 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string) => void;
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void;
@@ -462,6 +733,7 @@ export function ChatConversationView({
onSelectChatType,
onSend,
onSendImmediate,
onClearDraft,
onScrollToBottom,
onToggleResourceStrip,
onOpenPreview,
@@ -473,34 +745,88 @@ export function ChatConversationView({
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const orderedMessages = useMemo(() => {
const lastActivityIndexByKey = new Map<string, number>();
visibleMessages.forEach((message, index) => {
if (!isActivityLogMessage(message)) {
return;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
lastActivityIndexByKey.set(activityKey, index);
});
return visibleMessages.filter((message, index) => {
const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
const baseMessages = visibleMessages.filter((message) => {
if (!isActivityLogMessage(message)) {
return true;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
return lastActivityIndexByKey.get(activityKey) === index;
const activityKey = message.clientRequestId?.trim();
if (!activityKey) {
orphanActivityMessages.push(message);
return false;
}
latestActivityByRequestId.set(activityKey, message);
return false;
});
const insertedActivityRequestIds = new Set<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]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousHtmlOverflow = documentElement.style.overflow;
if (fullscreenPreviewKey) {
body.style.overflow = 'hidden';
documentElement.style.overflow = 'hidden';
}
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overflow = previousHtmlOverflow;
};
}, [fullscreenPreviewKey]);
const setActivitySectionRef = (requestId: string, element: HTMLElement | null) => {
if (element) {
@@ -651,6 +977,28 @@ export function ChatConversationView({
};
}, [orderedMessages, expandedMessageIds]);
useEffect(() => {
if (isConversationLoading) {
setShowBusyOverlay(false);
return;
}
if (!isComposerAttachmentUploading) {
setShowBusyOverlay(false);
return;
}
const timeoutId = window.setTimeout(() => {
setShowBusyOverlay(true);
}, 350);
return () => {
window.clearTimeout(timeoutId);
};
}, [isComposerAttachmentUploading, isConversationLoading]);
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
@@ -662,6 +1010,32 @@ export function ChatConversationView({
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 requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
@@ -748,7 +1122,19 @@ export function ChatConversationView({
</div>
) : 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">
<Button
type={isResourceStripOpen ? 'default' : 'text'}
@@ -821,18 +1207,29 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(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 requestStatusLabel = formatRequestStatusLabel(requestState);
const requestDetailText = getRequestDetailText(requestState);
return (
<div key={message.id} className={`app-chat-message-stack app-chat-message-stack--${message.author}`}>
<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">
@@ -919,7 +1316,7 @@ export function ChatConversationView({
}}
className={messageBodyClassName}
>
{message.text}
{visibleText ? renderMessageBody(visibleText) : null}
</div>
{message.author === 'user' && requestDetailText ? (
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
@@ -943,19 +1340,65 @@ export function ChatConversationView({
</Button>
) : null}
</article>
{inlinePreviewTargets.length > 0 ? (
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{inlinePreviewTargets.map((target) => (
<InlineMessagePreview
key={`${message.id}-${target.url}`}
target={target}
isExpanded={expandedPreviewKey === `${message.id}-${target.url}`}
{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={() => {
const nextKey = `${message.id}-${target.url}`;
setExpandedPreviewKey((current) => (current === nextKey ? null : nextKey));
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>
) : null}
</div>
@@ -1094,6 +1537,7 @@ export function ChatConversationView({
onChange={(event) => {
onDraftChange(event.target.value);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return;
@@ -1113,6 +1557,16 @@ export function ChatConversationView({
onSend();
}}
/>
<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
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 { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer';
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
import { triggerResourceDownload } from './downloadUtils';
import '../../../components/previewer/PreviewerUI.css';
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 = {
label: string;
@@ -16,6 +27,47 @@ export type ChatPreviewTarget = {
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) {
const normalized = previewError.trim();
@@ -247,10 +299,10 @@ export function ChatPreviewBody({
);
}
if (target.kind === 'code' || target.kind === 'document') {
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (resolvedLanguage === 'diff') {
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
<CodexDiffBlock
@@ -291,12 +343,16 @@ export function ChatPreviewBody({
. .
</Paragraph>
<Space wrap>
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
</Button>
<Button href={target.url} download icon={<DownloadOutlined />}>
</Button>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
</Space>
</div>
);

View File

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

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 {
buildOfflineReply,
clearStoredChatClientConversationState,
copyPreviewContent,
copyText,
resolvePreviewBodyForCopy,
createActivityLogPlaceholder,
createChatConversationRoom,
createChatMessage,
@@ -20,15 +22,14 @@ export {
getStoredChatSessionLastTypeId,
isPreparingChatReplyText,
getChatClientSessionId,
loadStoredChatMessages,
markChatConversationResponsesRead,
mergeRecoveredChatMessages,
persistStoredChatMessages,
renameChatConversationRoom,
removeChatRuntimeJob,
resetLastReceivedChatEventId,
setStoredChatSessionLastTypeId,
setChatClientSessionId,
sortChatConversationSummaries,
uploadChatComposerFile,
upsertChatMessage,
updateChatConversationRoom,

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;
clientId: string | null;
title: string;
chatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;

View File

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

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

View File

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

View File

@@ -2,11 +2,12 @@ import {
AudioOutlined,
CodeOutlined,
CopyOutlined,
EyeOutlined,
DownloadOutlined,
FileImageOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileTextOutlined,
FullscreenOutlined,
LinkOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
@@ -128,6 +129,81 @@ async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
document.body.removeChild(textarea);
}
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
if (typeof attachment.title === 'string' && attachment.title.trim()) {
return attachment.title.trim();
}
if (attachment.linkUrl) {
try {
const resolvedUrl = new URL(
attachment.linkUrl,
typeof window !== 'undefined' ? window.location.origin : 'https://test.sm-home.cloud/',
);
const fileName = resolvedUrl.pathname.split('/').pop()?.trim();
if (fileName) {
return fileName;
}
} catch {
return `${attachment.key}.txt`;
}
}
return `${attachment.key}.txt`;
}
function resolveAttachmentMimeType(attachment: EvidenceAttachmentItem) {
switch (attachment.kind) {
case 'markdown':
return 'text/markdown;charset=utf-8';
case 'json':
return 'application/json;charset=utf-8';
case 'code':
case 'text':
case 'empty':
return 'text/plain;charset=utf-8';
default:
return 'application/octet-stream';
}
}
function downloadAttachmentValue(attachment: EvidenceAttachmentItem) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const fileName = resolveAttachmentDownloadFileName(attachment);
const link = document.createElement('a');
if (attachment.linkUrl) {
link.href = attachment.linkUrl;
if (attachment.kind === 'preview') {
link.target = '_blank';
link.rel = 'noreferrer';
} else {
link.download = fileName;
}
} else {
const blob = new Blob([attachment.value], {
type: resolveAttachmentMimeType(attachment),
});
const objectUrl = URL.createObjectURL(blob);
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
return;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function resolvePreviewerType(kind: EvidenceAttachmentKind) {
switch (kind) {
case 'markdown':
@@ -260,6 +336,14 @@ export function EvidenceAttachmentStrip({
}
}
async function handleDownload(attachment: EvidenceAttachmentItem) {
try {
downloadAttachmentValue(attachment);
} catch {
message.error('다운로드에 실패했습니다.');
}
}
if (attachments.length === 0) {
return (
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
@@ -308,29 +392,27 @@ export function EvidenceAttachmentStrip({
void handleCopy(attachment);
}}
/>
{attachment.linkUrl ? (
{attachment.linkUrl || attachment.value ? (
<Button
type="link"
type="text"
size="small"
href={attachment.linkUrl}
target="_blank"
rel="noreferrer"
style={{ paddingInline: 0 }}
icon={<LinkOutlined />}
>
</Button>
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
void handleDownload(attachment);
}}
/>
) : null}
{onPreview ? (
<Button
size="small"
icon={<EyeOutlined />}
type="text"
aria-label="최대화"
icon={<FullscreenOutlined />}
onClick={() => {
void onPreview(attachment);
}}
>
</Button>
/>
) : null}
</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 type { SampleMeta } from '../../../widgets/core';
import {
@@ -96,6 +103,7 @@ export function Sample() {
const { message } = App.useApp();
const [compact, setCompact] = useState(false);
const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null);
const [isPreviewExpanded, setIsPreviewExpanded] = useState(false);
return (
<Flex vertical gap={16}>
@@ -130,13 +138,51 @@ export function Sample() {
open={Boolean(selectedAttachment)}
title={selectedAttachment?.title ?? 'Attachment Preview'}
footer={null}
width={1080}
width={isPreviewExpanded ? 'calc(100vw - 32px)' : 1080}
onCancel={() => {
setSelectedAttachment(null);
setIsPreviewExpanded(false);
}}
>
{selectedAttachment ? (
<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}
</Modal>
</Flex>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import {
CopyOutlined,
DownloadOutlined,
DownOutlined,
FileImageOutlined,
FileTextOutlined,
@@ -92,7 +93,7 @@ export function CodexDiffPreviewer({
}: CodexDiffPreviewerProps) {
const [messageApi, contextHolder] = message.useMessage();
const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff');
const [expandedSourcePaths, setExpandedSourcePaths] = useState<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 statusCount = useMemo(() => buildStatusCount(files), [files]);
const canShowSource = files.length > 0;
@@ -129,6 +130,24 @@ export function CodexDiffPreviewer({
}
}
function handleDownload(path: string, content: string) {
if (typeof document === 'undefined') {
messageApi.error('다운로드를 사용할 수 없습니다.');
return;
}
const fileName = path.split('/').pop() || 'preview.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function handleFullscreen(path: string) {
setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path));
}
@@ -141,15 +160,12 @@ export function CodexDiffPreviewer({
return;
}
setExpandedSourcePaths((currentPaths) => {
const availablePaths = new Set(files.map((file) => file.path));
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
if (nextPaths.length > 0) {
return nextPaths;
setExpandedSourcePath((currentPath) => {
if (currentPath && files.some((file) => file.path === currentPath)) {
return currentPath;
}
return files[0] ? [files[0].path] : [];
return files[0]?.path ?? null;
});
}, [canShowSource, diffText, files]);
@@ -231,15 +247,15 @@ export function CodexDiffPreviewer({
<Button
size="small"
onClick={() => {
setExpandedSourcePaths(files.map((file) => file.path));
setExpandedSourcePath(files[0]?.path ?? null);
}}
>
</Button>
<Button
size="small"
onClick={() => {
setExpandedSourcePaths([]);
setExpandedSourcePath(null);
}}
>
@@ -248,7 +264,7 @@ export function CodexDiffPreviewer({
</div>
{files.map((file) => {
const isExpanded = expandedSourcePaths.includes(file.path);
const isExpanded = expandedSourcePath === file.path;
const isPreviewExpanded = expandedPreviewPath === file.path;
const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path;
@@ -266,11 +282,7 @@ export function CodexDiffPreviewer({
type="button"
className="codex-diff-previewer__diff-toggle"
onClick={() => {
setExpandedSourcePaths((currentPaths) =>
currentPaths.includes(file.path)
? currentPaths.filter((path) => path !== file.path)
: [...currentPaths, file.path],
);
setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path));
}}
>
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
@@ -296,6 +308,16 @@ export function CodexDiffPreviewer({
void handleCopy(file.content);
}}
/>
<Button
size="small"
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={(event) => {
event.stopPropagation();
handleDownload(file.path, file.content);
}}
/>
<Button
size="small"
type="text"
@@ -316,6 +338,7 @@ export function CodexDiffPreviewer({
value={file.content}
language={file.language}
imageAlt={file.path.split('/').pop() ?? file.path}
downloadFileName={file.path.split('/').pop() ?? file.path}
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
copyable={false}
maximizable={false}

View File

@@ -93,6 +93,15 @@
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 {
border-color: transparent;
border-radius: 0;
@@ -105,17 +114,18 @@
.previewer-ui--expanded {
position: fixed;
inset: 16px;
inset: 0;
z-index: 1200;
height: auto;
border-radius: 20px;
border-radius: 0;
border-inline: 0;
box-shadow:
0 24px 64px rgba(15, 23, 42, 0.28),
0 12px 28px rgba(15, 23, 42, 0.16);
}
.previewer-ui--expanded .previewer-ui__body {
height: calc(100vh - 32px - 53px) !important;
height: calc(100vh - 53px) !important;
}
.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 type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
@@ -136,6 +136,22 @@ async function copyText(text: string) {
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'>) {
switch (type) {
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({
type,
value,
@@ -212,6 +267,10 @@ export function PreviewerUI({
value,
copyValue,
copyable = true,
downloadable = true,
downloadValue,
downloadUrl,
downloadFileName,
maximizable = true,
language = 'text',
format = 'auto',
@@ -226,8 +285,11 @@ export function PreviewerUI({
const [isExpanded, setIsExpanded] = useState(false);
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
const shouldShowActions = hasLanguageSelector || canCopy || maximizable || Boolean(toolbar);
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
useEffect(() => {
if (!isExpanded || typeof document === 'undefined') {
@@ -271,6 +333,37 @@ export function PreviewerUI({
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 = (
<>
{hasLanguageSelector ? (
@@ -286,15 +379,27 @@ export function PreviewerUI({
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="복사"
icon={<CopyOutlined />}
onClick={() => void handleCopy()}
/>
) : null}
{canDownload ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={handleDownload}
/>
) : null}
{maximizable ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label={isExpanded ? '최대화 해제' : '최대화'}
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => void toggleFullscreen()}

View File

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

View File

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

View File

@@ -17,6 +17,13 @@ body,
overflow-x: hidden;
}
html,
body {
background:
radial-gradient(circle at top, rgba(22, 93, 255, 0.14), transparent 34%),
linear-gradient(180deg, #f8fbff 0%, #eef4ff 45%, #ffffff 100%);
}
.markdown-preview__image {
display: block;
width: 100%;
@@ -35,6 +42,7 @@ body {
min-width: 320px;
font-family:
'SUIT Variable', 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, sans-serif;
color: #182230;
}
img,

View File

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

View File

@@ -1,5 +1,6 @@
import { cpSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join } from 'path';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { defineConfig, type ResolvedConfig, type ViteDevServer } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
@@ -19,6 +20,8 @@ const VITE_PUBLIC_HMR_CLIENT_PORT = Number(processEnv.VITE_PUBLIC_HMR_CLIENT_POR
const VITE_EMPTY_OUT_DIR = processEnv.VITE_EMPTY_OUT_DIR !== 'false';
const VITE_FILTER_PUBLIC_DIR = processEnv.VITE_FILTER_PUBLIC_DIR === 'true';
const VITE_DISABLE_MODULE_PRELOAD = processEnv.VITE_DISABLE_MODULE_PRELOAD === 'true';
const VITE_DISABLE_PWA = processEnv.VITE_DISABLE_PWA === 'true';
const ROOT_DIR = fileURLToPath(new URL('.', import.meta.url));
function shouldIgnoreDevUpdatePath(watchedPath: string) {
return (
@@ -121,6 +124,13 @@ function copyPublicAssetsExceptCodexChat() {
}
export default defineConfig({
resolve: {
alias: VITE_DISABLE_PWA
? {
'virtual:pwa-register': resolve(ROOT_DIR, 'src/app/main/pwaRegisterStub.ts'),
}
: undefined,
},
build: {
copyPublicDir: !VITE_FILTER_PUBLIC_DIR,
emptyOutDir: VITE_EMPTY_OUT_DIR,
@@ -163,16 +173,20 @@ export default defineConfig({
react(),
createDevAppUpdatePlugin(),
copyPublicAssetsExceptCodexChat(),
!VITE_DISABLE_PWA &&
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'prompt',
includeAssets: ['favicon.svg', 'apple-touch-icon.svg'],
workbox: {
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
globIgnores: ['**/.codex_chat/**'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
},
manifest: {
name: 'AI Code App',
@@ -202,5 +216,5 @@ export default defineConfig({
enabled: true,
},
}),
],
].filter(Boolean),
});