Fix chat type persistence and board flow

This commit is contained in:
2026-04-24 15:56:30 +09:00
parent c07b0b12af
commit d53532508b
38 changed files with 2358 additions and 912 deletions

View File

@@ -55,6 +55,7 @@
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
---

View File

@@ -4,7 +4,7 @@
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비하고, 작업 결과를 `release` 반영 후 `main`까지 반영하는 흐름입니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
## 구현 위치
@@ -122,7 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다.
자동화 작업의 세부 Git 절차와 예외 처리 순서는 실제 worker 구현과 현재 설정값을 함께 확인합니다.
## 차트 집계 방식

View File

@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}"
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
@@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
exit 1
fi
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
if [ -n "$RUNNER_PIDS" ]; then
kill $RUNNER_PIDS || true
if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
chmod +x "$SUPERVISOR_SCRIPT"
fi
if [ -f "$SUPERVISOR_PID_FILE" ]; then
SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true)
if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then
kill -HUP "$SUPERVISOR_PID"
echo "server-command-runner reload requested"
exit 0
fi
rm -f "$SUPERVISOR_PID_FILE"
fi
LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
if [ -n "$LEGACY_RUNNER_PIDS" ]; then
kill $LEGACY_RUNNER_PIDS || true
sleep 1
fi
setsid env \
PROJECT_ROOT="$PROJECT_ROOT" \
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
SERVER_COMMAND_RUNNER_SCRIPT="$RUNNER_SCRIPT" \
SERVER_COMMAND_RUNNER_NODE_BIN="$RUNNER_NODE_BIN" \
SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE="$SUPERVISOR_PID_FILE" \
"$SUPERVISOR_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
echo "server-command-runner restart requested"

View File

@@ -4,6 +4,21 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
cd "$REPO_ROOT"
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
if docker inspect work-server >/dev/null 2>&1; then
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
case "$SUPERVISOR_CMD" in
*work-server-supervisor*)
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
echo "work-server reload requested"
exit 0
fi
;;
esac
fi
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server

View File

@@ -2,6 +2,7 @@ create table if not exists board_posts (
id serial primary key,
title varchar(200) not null,
content text not null,
attachments_json text not null default '[]',
automation_plan_item_id integer null,
automation_received_at timestamptz null,
created_at timestamptz not null default now(),

View File

@@ -0,0 +1,4 @@
.docker
node_modules
dist
npm-debug.log*

View File

@@ -11,4 +11,7 @@ COPY src ./src
RUN npm run build
CMD ["npm", "run", "start"]
COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor
RUN chmod +x /usr/local/bin/work-server-supervisor
CMD ["/usr/local/bin/work-server-supervisor"]

View File

@@ -22,6 +22,8 @@ services:
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro
@@ -50,3 +52,6 @@ services:
networks:
work-backend:
name: work-backend
volumes:
work-server-node-modules:

View File

@@ -0,0 +1,96 @@
#!/bin/sh
set -eu
APP_ROOT="${APP_ROOT:-/app}"
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
LOCK_FILE="$APP_ROOT/package-lock.json"
LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256"
CHILD_PID=""
STOP_REQUESTED="0"
RELOAD_REQUESTED="0"
mkdir -p "$STATE_DIR"
cd "$APP_ROOT"
log() {
printf '[work-server-supervisor] %s\n' "$*"
}
ensure_dependencies() {
if [ ! -f "$LOCK_FILE" ]; then
log "package-lock.json not found; skipping npm ci"
return 0
fi
CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}')
PREVIOUS_HASH=""
if [ -f "$LOCK_HASH_FILE" ]; then
PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE")
fi
if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then
log "installing dependencies"
npm ci --legacy-peer-deps
printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE"
fi
}
prepare_runtime() {
ensure_dependencies
log "building latest source"
npm run build
}
start_child() {
log "starting server process"
npm run start &
CHILD_PID=$!
}
request_reload() {
log "reload requested"
if prepare_runtime; then
RELOAD_REQUESTED="1"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
else
log "reload aborted because build failed; keeping current process"
fi
}
request_stop() {
STOP_REQUESTED="1"
log "shutdown requested"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
}
trap 'request_reload' HUP
trap 'request_stop' INT TERM
prepare_runtime
while :; do
start_child
set +e
wait "$CHILD_PID"
EXIT_CODE=$?
set -e
CHILD_PID=""
if [ "$STOP_REQUESTED" = "1" ]; then
exit "$EXIT_CODE"
fi
if [ "$RELOAD_REQUESTED" = "1" ]; then
RELOAD_REQUESTED="0"
continue
fi
log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
sleep 2
done

View File

@@ -32,6 +32,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
async function ensureAppConfigTable() {

View File

@@ -28,7 +28,7 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -82,7 +82,7 @@ function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: defaultItem.description,
description: existingItem.description || defaultItem.description,
behaviorType: defaultItem.behaviorType,
});
}

View File

@@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
assert.equal(
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
}),
buildBoardPostPlanNote(
' 알림 개선 ',
'본문 첫 줄\n본문 둘째 줄\n',
[],
{
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
},
),
[
'# 자동화 작업메모',
'',
@@ -27,10 +32,15 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
assert.equal(
buildBoardPostPlanNote('작업', '본문', {
name: '빈 context 유형',
description: ' ',
}),
buildBoardPostPlanNote(
'작업',
'본문',
[],
{
name: '빈 context 유형',
description: ' ',
},
),
[
'# 자동화 작업메모',
'',
@@ -48,6 +58,42 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
);
});
test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
assert.equal(
buildBoardPostPlanNote(
'작업',
'본문',
[
{
id: 'attachment-1',
name: 'spec.png',
path: 'public/.codex_chat/test/resource/uploads/spec.png',
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
size: 1280,
mimeType: 'image/png',
},
],
null,
),
[
'# 자동화 작업메모',
'',
'- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
'선택된 자동화 유형 context 없음',
'',
'## 요청 본문',
'본문',
'',
'## 첨부 파일',
'- spec.png: public/.codex_chat/test/resource/uploads/spec.png',
].join('\n'),
);
});
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');

View File

@@ -17,6 +17,16 @@ export const BOARD_POSTS_TABLE = 'board_posts';
export const boardPostPayloadSchema = z.object({
title: z.string().trim().min(1).max(200),
content: z.string().min(1).max(200000),
attachments: z.array(
z.object({
id: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(255),
path: z.string().trim().min(1).max(2000),
publicUrl: z.string().trim().min(1).max(2000),
size: z.coerce.number().int().min(0).max(10 * 1024 * 1024),
mimeType: z.string().trim().min(1).max(200),
}),
).max(20).default([]),
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
});
@@ -25,6 +35,7 @@ export type BoardPostItem = {
title: string;
content: string;
preview: string;
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'];
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
automationPlanItemId: number | null;
automationReceivedAt: string | null;
@@ -57,12 +68,22 @@ function createPreview(content: string) {
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
const content = String(row.content ?? '');
const rawAttachments = row.attachments_json ?? row.attachments ?? '[]';
let attachments: BoardPostItem['attachments'] = [];
try {
const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments;
attachments = boardPostPayloadSchema.shape.attachments.parse(parsed);
} catch {
attachments = [];
}
return {
id: Number(row.id ?? 0),
title: String(row.title ?? ''),
content,
preview: createPreview(content),
attachments,
automationType: resolveStoredAutomationTypeId(row),
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
? null
@@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
return Boolean(row.automation_received_at || row.automation_plan_item_id);
}
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloadSchema>['attachments']) {
const lines = attachments
.map((attachment) => {
const label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일';
const path = attachment.path.trim();
if (!path) {
return null;
}
return `- ${label}: ${path}`;
})
.filter((line): line is string => Boolean(line));
if (lines.length === 0) {
return [];
}
return ['## 첨부 파일', ...lines];
}
export function buildBoardPostPlanNote(
title: string,
content: string,
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
) {
const normalizedTitle = title.trim();
const normalizedContent = content.trim();
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
@@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio
'',
'## 요청 본문',
normalizedContent,
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
]
.filter((line): line is string => line !== null)
.join('\n');
@@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() {
['content', (table) => table.text('content').notNullable().defaultTo('')],
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -230,6 +279,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
const insertQuery = db(BOARD_POSTS_TABLE).insert({
title: parsedPayload.title,
content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
created_at: db.fn.now(),
@@ -276,11 +326,12 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim();
const attachments = mapBoardPostRow(currentRow).attachments;
const workId = `board-post-${id}`;
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
const insertQuery = trx(PLAN_TABLE).insert({
work_id: workId,
note: buildBoardPostPlanNote(title, content, automationType),
note: buildBoardPostPlanNote(title, content, attachments, automationType),
automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
status: '등록',
@@ -344,6 +395,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
.update({
title: parsedPayload.title,
content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
updated_at: trx.fn.now(),

View File

@@ -1070,15 +1070,25 @@ export async function updateChatConversationContext(
return null;
}
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
const requestedChatTypeId = payload.chatTypeId?.trim() || null;
const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null;
const requestedContextLabel = payload.contextLabel?.trim() || null;
const requestedContextDescription = payload.contextDescription?.trim() || null;
await db(CHAT_CONVERSATION_TABLE)
.where({ session_id: sessionId.trim() })
.update({
title: payload.title?.trim() || current.title || '새 대화',
client_id: normalizedClientId || current.client_id || null,
chat_type_id: payload.chatTypeId?.trim() || null,
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
context_label: payload.contextLabel?.trim() || null,
context_description: payload.contextDescription?.trim() || null,
chat_type_id: nextChatTypeId,
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
context_label:
currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null,
context_description:
currentChatTypeId != null
? current.context_description || null
: requestedContextDescription || current.context_description || null,
notify_offline:
normalizedClientId == null && payload.notifyOffline != null
? payload.notifyOffline
@@ -1613,14 +1623,21 @@ 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,
chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null,
last_chat_type_id:
conversation.chatTypeId?.trim() ||
currentConversation?.last_chat_type_id ||
currentConversation?.chat_type_id ||
currentConversation?.last_chat_type_id ||
conversation.chatTypeId?.trim() ||
conversation.lastChatTypeId?.trim() ||
null,
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
context_label:
currentConversation?.chat_type_id || currentConversation?.context_label
? currentConversation?.context_label || null
: conversation.contextLabel?.trim() || null,
context_description:
currentConversation?.chat_type_id || currentConversation?.context_description
? currentConversation?.context_description || null
: conversation.contextDescription?.trim() || null,
notify_offline:
conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
updated_at: db.fn.now(),

View File

@@ -8,6 +8,7 @@ import {
collectOfflineNotificationClientIds,
createActivityLogMessage,
extractDiffCodeBlocks,
extractCodexStreamText,
fitActivityLogLines,
isAutomationRegistrationCountRequest,
resolveResponseTimestamp,
@@ -116,6 +117,45 @@ test('createActivityLogMessage keeps fitted activity history instead of the late
);
});
test('extractCodexStreamText ignores command execution item completions', () => {
assert.deepEqual(
extractCodexStreamText({
type: 'item.completed',
item: {
id: 'item_1',
type: 'command_execution',
command: '/bin/bash -lc pwd',
aggregated_output: '/workspace/main-project\n',
exit_code: 0,
status: 'completed',
},
}),
{
type: 'item.completed',
completedText: '',
deltaText: '',
},
);
});
test('extractCodexStreamText keeps completed agent messages', () => {
assert.deepEqual(
extractCodexStreamText({
type: 'item.completed',
item: {
id: 'item_2',
type: 'agent_message',
text: '최종 응답입니다.',
},
}),
{
type: 'item.completed',
completedText: '최종 응답입니다.',
deltaText: '',
},
);
});
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
});

View File

@@ -1041,11 +1041,25 @@ function collectCodexTextFragments(value: unknown): string[] {
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
}
function extractCompletedAgentMessageText(item: unknown) {
if (!item || typeof item !== 'object') {
return '';
}
const record = item as Record<string, unknown>;
if (record.type !== 'agent_message') {
return '';
}
return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
}
export function extractCodexStreamText(parsed: Record<string, unknown>) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') {
const completedText = collectCodexTextFragments(parsed.item).join('');
const completedText = extractCompletedAgentMessageText(parsed.item);
return {
type,
completedText,
@@ -3186,9 +3200,19 @@ export class ChatService {
});
const progressMessages = buildProgressMessages(request.text);
let progressIndex = 0;
let progressIndex = progressMessages.length > 1 ? 1 : 0;
let lastProgressMessage = progressMessages[0] ?? '';
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
if (!nextMessage || nextMessage === lastProgressMessage) {
if (progressIndex >= progressMessages.length - 1) {
stopProgressTimer();
}
return;
}
lastProgressMessage = nextMessage;
chatRuntimeService.appendLog(request.requestId, nextMessage);
appendActivityLine(`# 진행: ${nextMessage}`);
@@ -3199,6 +3223,8 @@ export class ChatService {
if (progressIndex < progressMessages.length - 1) {
progressIndex += 1;
} else {
stopProgressTimer();
}
}, 2200);

View File

@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
const createdPost = await createBoardPost({
title: buildErrorLogBoardPostTitle(candidate),
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [],
automationType: 'none',
});

0
main Normal file
View File

View File

@@ -29,6 +29,14 @@ const runnerLogTrimIntervalMs = Math.max(
Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'),
);
const STREAM_CAPTURE_LIMIT = 256 * 1024;
const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max(
30_000,
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'),
);
const CODEX_LIVE_MAX_EXECUTION_MS = Math.max(
CODEX_LIVE_IDLE_TIMEOUT_MS,
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`),
);
const CODEX_HOME_RUNTIME_PATHS = [
'auth.json',
'config.toml',
@@ -342,12 +350,24 @@ function collectCodexTextFragments(value) {
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
}
function extractCompletedAgentMessageText(item) {
if (!item || typeof item !== 'object') {
return '';
}
if (item.type !== 'agent_message') {
return '';
}
return collectCodexTextFragments(item.text ?? item.content ?? item.message).join('');
}
function extractCodexStreamText(parsed) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') {
return {
completedText: collectCodexTextFragments(parsed.item).join(''),
completedText: extractCompletedAgentMessageText(parsed.item),
deltaText: '',
};
}
@@ -504,6 +524,9 @@ async function runCodexLiveExecution(payload, response) {
let jsonLineBuffer = '';
let completedText = '';
let responseClosed = false;
let idleTimer = null;
let executionTimer = null;
let terminationRequested = false;
response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8',
@@ -540,6 +563,68 @@ async function runCodexLiveExecution(payload, response) {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
};
const clearExecutionTimers = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (executionTimer) {
clearTimeout(executionTimer);
executionTimer = null;
}
};
const requestTermination = (message) => {
if (terminationRequested) {
return;
}
terminationRequested = true;
clearExecutionTimers();
if (!responseClosed) {
sendJsonLine(response, {
type: 'error',
message,
});
response.end();
responseClosed = true;
}
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 3000).unref?.();
};
const refreshIdleTimer = () => {
if (terminationRequested) {
return;
}
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`,
);
}, CODEX_LIVE_IDLE_TIMEOUT_MS);
idleTimer.unref?.();
};
executionTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(CODEX_LIVE_MAX_EXECUTION_MS / 1000)}초를 넘어 중단되었습니다.`,
);
}, CODEX_LIVE_MAX_EXECUTION_MS);
executionTimer.unref?.();
refreshIdleTimer();
const handleCodexJsonLine = (line) => {
let parsed;
@@ -552,6 +637,7 @@ async function runCodexLiveExecution(payload, response) {
const activityLog = extractCodexActivityLog(parsed);
if (activityLog) {
refreshIdleTimer();
sendJsonLine(response, {
type: 'activity',
line: activityLog,
@@ -561,6 +647,7 @@ async function runCodexLiveExecution(payload, response) {
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
if (nextCompletedText) {
refreshIdleTimer();
completedText = nextCompletedText;
sendJsonLine(response, {
type: 'completed',
@@ -570,6 +657,7 @@ async function runCodexLiveExecution(payload, response) {
}
if (deltaText) {
refreshIdleTimer();
sendJsonLine(response, {
type: 'delta',
text: deltaText,
@@ -582,9 +670,11 @@ async function runCodexLiveExecution(payload, response) {
response.on('close', () => {
responseClosed = true;
clearExecutionTimers();
});
child.stdout?.on('data', (chunk) => {
refreshIdleTimer();
const text = String(chunk);
stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT);
jsonLineBuffer += text;
@@ -612,6 +702,7 @@ async function runCodexLiveExecution(payload, response) {
});
child.stderr?.on('data', (chunk) => {
refreshIdleTimer();
const text = String(chunk);
stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT);
text
@@ -631,6 +722,7 @@ async function runCodexLiveExecution(payload, response) {
});
child.on('error', async (error) => {
clearExecutionTimers();
if (!responseClosed) {
sendJsonLine(response, {
type: 'error',
@@ -643,6 +735,7 @@ async function runCodexLiveExecution(payload, response) {
});
child.on('close', async (code) => {
clearExecutionTimers();
const trailingLine = jsonLineBuffer.trim();
if (trailingLine) {
handleCodexJsonLine(trailingLine);

View File

@@ -0,0 +1,69 @@
#!/bin/sh
set -eu
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
CHILD_PID=""
STOP_REQUESTED="0"
RELOAD_REQUESTED="0"
log() {
printf '[server-command-runner-supervisor] %s\n' "$*"
}
cleanup() {
rm -f "$SUPERVISOR_PID_FILE"
}
start_child() {
log "starting runner child"
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" &
CHILD_PID=$!
}
request_reload() {
RELOAD_REQUESTED="1"
log "reload requested"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
}
request_stop() {
STOP_REQUESTED="1"
log "shutdown requested"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
}
trap 'request_reload' HUP
trap 'request_stop' INT TERM
trap 'cleanup' EXIT
printf '%s\n' "$$" >"$SUPERVISOR_PID_FILE"
cd "$PROJECT_ROOT"
while :; do
start_child
set +e
wait "$CHILD_PID"
EXIT_CODE=$?
set -e
CHILD_PID=""
if [ "$STOP_REQUESTED" = "1" ]; then
exit "$EXIT_CODE"
fi
if [ "$RELOAD_REQUESTED" = "1" ]; then
RELOAD_REQUESTED="0"
continue
fi
log "runner exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
sleep 2
done

View File

@@ -1,12 +1,13 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
PlusOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
@@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() {
}, [automationTypes, selectedAutomationTypeId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [form, isCreating, selectedAutomationType]);
}, [detailMode, form, isCreating, selectedAutomationType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -163,6 +169,41 @@ export function AutomationTypeManagementPage() {
}
};
const detailHeaderActions = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '등록' : '수정 저장'}>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
</Tooltip>
<Tooltip title="새 입력">
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
</Tooltip>
{!isCreating && selectedAutomationType ? (
<Tooltip title="삭제">
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
</Tooltip>
) : null}
<Tooltip title="목록 가기">
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Tooltip>
</Space>
);
if (!hasAccess) {
return (
<Card title="자동화 유형 관리" className="chat-type-management-page">
@@ -250,13 +291,11 @@ export function AutomationTypeManagementPage() {
<Card
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
>
<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 ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
</div>
<Form
className="chat-type-management-page__editor-form"
@@ -290,172 +329,150 @@ export function AutomationTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 12, maxRows: 24 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedAutomationType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -2,6 +2,8 @@
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
@@ -17,21 +19,31 @@
min-height: 0;
}
.chat-type-management-page__card {
flex: 1 1 auto;
}
.chat-type-management-page .ant-card,
.chat-type-management-page__card {
display: flex;
flex-direction: column;
}
.chat-type-management-page .ant-card-head {
min-height: 52px;
padding: 0 14px;
min-height: 44px;
padding: 0 12px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra {
padding: 10px 0;
padding: 6px 0;
}
.chat-type-management-page .ant-card-body {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 14px;
padding: 4px 14px 12px;
}
.chat-type-management-page__list,
@@ -40,7 +52,7 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
height: 100%;
overflow: hidden;
}
@@ -57,12 +69,23 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
overflow: hidden;
}
.chat-type-management-page__editor-scroll {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
overflow: auto;
padding: 0 0 8px;
}
.chat-type-management-page__editor-form .ant-form-item {
margin-bottom: 8px;
margin-bottom: 6px;
}
.chat-type-management-page__list-header {
@@ -73,7 +96,7 @@
}
.chat-type-management-page__list-header .ant-typography {
margin-bottom: 0;
margin: 0;
}
.chat-type-management-page__item {
@@ -137,15 +160,28 @@
.chat-type-management-page__editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
justify-content: flex-end;
gap: 8px;
}
.chat-type-management-page__header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.chat-type-management-page__header-actions .ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
}
.chat-type-management-page__markdown-grid {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 12px;
align-items: stretch;
flex: 1;
min-height: 0;
@@ -173,6 +209,15 @@
margin-bottom: 0;
}
.chat-type-management-page__markdown-pane .ant-form-item-control,
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.chat-type-management-page__markdown-pane--desktop-hidden {
display: none;
}
@@ -191,13 +236,13 @@
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 180px;
min-height: 360px;
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 180px;
min-height: 360px;
overflow: auto !important;
resize: none;
}
@@ -209,10 +254,10 @@
border: 1px solid #f0f0f0;
border-radius: 12px;
background: #fafafa;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@@ -226,23 +271,11 @@
margin-bottom: 0;
}
.chat-type-management-page__form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 2px;
}
.chat-type-management-page__form-actions--compact {
padding-top: 0;
}
.chat-type-management-page__meta-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: start;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 14px;
align-items: end;
}
.chat-type-management-page__meta-grid--hidden {
@@ -251,16 +284,45 @@
.chat-type-management-page__meta-item {
min-width: 0;
margin-bottom: 0;
}
.chat-type-management-page__meta-item .ant-form-item-label {
padding-bottom: 4px;
padding-bottom: 2px;
}
.chat-type-management-page__meta-item .ant-form-item-control-input {
min-height: 40px;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
min-height: 40px;
}
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
margin-inline-start: 0;
}
.chat-type-management-page__meta-item--name {
grid-column: 1 / -1;
}
.chat-type-management-page__meta-item--enabled {
justify-self: end;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
display: flex;
justify-content: flex-end;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
@@ -293,6 +355,23 @@
min-height: 0;
}
.chat-type-management-page {
flex: 1 1 auto;
min-height: 0;
}
.chat-type-management-page .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page__editor-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.chat-type-management-page__list-header {
align-items: flex-start;
}
@@ -305,7 +384,18 @@
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra,
.chat-type-management-page .ant-card-body {
padding: 10px;
padding: 7px 10px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra {
padding-top: 6px;
padding-bottom: 6px;
}
.chat-type-management-page__editor-scroll {
gap: 3px;
padding: 0 0 6px;
}
.chat-type-management-page__mobile-toggle {
@@ -314,28 +404,83 @@
.chat-type-management-page__editor-toolbar {
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
}
.chat-type-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 12px;
align-items: start;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
justify-content: flex-end;
}
.chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
min-height: 0;
}
.chat-type-management-page__markdown-pane--mobile-hidden {
display: none;
}
.chat-type-management-page__markdown-pane,
.chat-type-management-page__markdown-field,
.chat-type-management-page__markdown-editor,
.chat-type-management-page__markdown-preview {
overflow: hidden;
}
.chat-type-management-page__markdown-pane .ant-form-item-control,
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
flex: 1 1 auto;
min-height: 0;
}
.chat-type-management-page__markdown-textarea,
.chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page__markdown-preview-body {
min-height: 0;
}
.chat-type-management-page__form-actions {
flex-wrap: wrap;
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 0;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
overflow: auto !important;
}
.chat-type-management-page__markdown-preview {
padding: 8px 10px;
}
.chat-type-management-page__markdown-preview-body {
max-height: none;
overflow: auto;
}
.chat-type-management-page__header-actions {
gap: 4px;
}
.chat-type-management-page__header-actions .ant-btn {
width: 34px;
min-width: 34px;
height: 34px;
}
}

View File

@@ -1,12 +1,13 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
PlusOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
@@ -82,8 +83,13 @@ export function ChatTypeManagementPage() {
}, [chatTypes, selectedChatTypeId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
}, [form, isCreating, selectedChatType]);
}, [detailMode, form, isCreating, selectedChatType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -145,7 +151,7 @@ export function ChatTypeManagementPage() {
return;
}
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) {
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
return;
}
@@ -167,6 +173,52 @@ export function ChatTypeManagementPage() {
}
};
const detailHeaderActions = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '저장' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
</Tooltip>
<Tooltip title="새 입력">
<Button
shape="circle"
icon={<PlusOutlined />}
disabled={isSaving}
aria-label="새 입력"
onClick={openCreateForm}
/>
</Tooltip>
{!isCreating && selectedChatType ? (
<Tooltip title="비활성화">
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="비활성화"
onClick={() => void handleDelete()}
/>
</Tooltip>
) : null}
<Tooltip title="목록 가기">
<Button
shape="circle"
icon={<UnorderedListOutlined />}
aria-label="목록 가기"
onClick={closeDetail}
/>
</Tooltip>
</Space>
);
if (!hasAccess) {
return (
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
@@ -267,13 +319,11 @@ export function ChatTypeManagementPage() {
<Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
>
<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>
</div>
<Form
className="chat-type-management-page__editor-form"
@@ -301,187 +351,165 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="권한 대상"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="사용 권한"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -2131,12 +2131,12 @@
z-index: 4;
display: flex;
flex-direction: column;
gap: 6px;
width: min(240px, calc(100% - 16px));
max-height: min(28vh, 180px);
padding: 8px;
gap: 8px;
width: min(420px, calc(100% - 16px));
max-height: min(58vh, 520px);
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 12px;
border-radius: 16px;
background: rgba(246, 248, 252, 0.96);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
overflow: hidden;
@@ -2145,7 +2145,7 @@
.app-chat-panel__resource-strip-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
overflow: auto;
}
@@ -2171,22 +2171,18 @@
line-height: 1.5;
}
.app-chat-panel__resource-chip {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
cursor: pointer;
font-size: 11px;
text-align: left;
.app-chat-panel__resource-strip .app-chat-preview-card {
margin: 0;
}
.app-chat-panel__resource-strip .app-chat-preview-card__body {
padding-top: 8px;
}
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
.app-chat-panel__resource-strip .previewer-ui__editor,
.app-chat-panel__resource-strip .previewer-ui__editor-body {
min-height: 0;
}
.app-chat-panel__preview-stage {
@@ -2596,10 +2592,6 @@
padding-bottom: 2px;
}
.app-chat-panel__resource-chip {
min-width: 160px;
}
.app-chat-panel__preview-image,
.app-chat-panel__preview-video,
.app-chat-panel__preview-frame,

View File

@@ -6,6 +6,7 @@ import {
CopyOutlined,
DownloadOutlined,
EditOutlined,
EyeOutlined,
ExclamationCircleOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -34,6 +35,7 @@ import { useConversationViewportController } from './chatV2/hooks/useConversatio
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
@@ -47,9 +49,7 @@ import {
createChatMessage,
createLocalMessage,
ErrorLogViewer,
getStoredChatSessionLastTypeId,
isPreparingChatReplyText,
setStoredChatSessionLastTypeId,
sortChatConversationSummaries,
upsertChatMessage,
useErrorLogs,
@@ -666,7 +666,7 @@ function isPreviewRouteUrl(url: string) {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
@@ -725,14 +725,28 @@ function buildPreviewLabel(url: string, source: PreviewItem['source']) {
}
}
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
if (!item || item.kind !== 'code') {
return false;
}
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const pathname = parsed.pathname.toLowerCase();
return pathname.endsWith('.html') || pathname.endsWith('.htm');
} catch {
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
return pathname.endsWith('.html') || pathname.endsWith('.htm');
}
}
function extractPreviewItems(messages: ChatMessage[]) {
const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const seen = new Set<string>();
const items: PreviewItem[] = [];
const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => {
const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
@@ -895,7 +909,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
[chatTypes, userRoles],
);
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
const requestedSessionId = getSessionIdFromSearch(location.search);
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
const requestedChatView = getRequestedChatViewFromSearch(location.search);
@@ -940,6 +955,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
const previewSearchMatchIndexRef = useRef(-1);
const previewSearchKeyRef = useRef('');
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
const titleClusterRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<number | null>(null);
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
@@ -950,7 +966,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const shouldRestoreConversationAfterReconnectRef = useRef(false);
const handledRequestedSessionIdRef = useRef('');
const isClosingConversationRef = useRef(false);
const lastChatTypeSessionIdRef = useRef('');
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
@@ -966,19 +981,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
});
}, []);
const currentContext: ChatViewContext = {
pageId: currentPage.id,
pageTitle: currentPage.title,
topMenu: currentPage.topMenu,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
isStandaloneMode: isStandaloneDisplayMode(),
pageVisibilityState:
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
chatTypeId: selectedChatType?.id ?? null,
chatTypeLabel: selectedChatType?.name ?? '',
chatTypeDescription: selectedChatType?.description ?? '',
};
const {
conversationItems,
setConversationItems,
@@ -1029,14 +1031,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const handleCreateConversation = async () => {
const sessionId = createConversationSessionId();
const now = new Date().toISOString();
const nextConversationChatType =
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
const optimisticItem: ChatConversationSummary = {
sessionId,
clientId: null,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name ?? null,
contextDescription: selectedChatType?.description ?? null,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name ?? null,
contextDescription: nextConversationChatType?.description ?? null,
notifyOffline: true,
hasUnreadResponse: false,
currentRequestId: null,
@@ -1059,10 +1063,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const item = await chatGateway.createConversation({
sessionId,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name,
contextDescription: selectedChatType?.description,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name,
contextDescription: nextConversationChatType?.description,
notifyOffline: true,
});
@@ -1384,6 +1388,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
};
const previewItems = useMemo(
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
[messages],
);
const isTabletAppLayout = isMobileViewport;
const chatMessages = useMemo(
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
[messages],
);
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
const persistedActiveChatTypeId =
activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null;
const effectiveChatType = useMemo(() => {
if (persistedActiveChatTypeId) {
const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId);
if (persistedChatType) {
return persistedChatType;
}
return {
id: persistedActiveChatTypeId,
name: activeConversation?.contextLabel?.trim() || persistedActiveChatTypeId,
description: activeConversation?.contextDescription?.trim() || '',
};
}
return selectedChatType;
}, [
activeConversation?.contextDescription,
activeConversation?.contextLabel,
chatTypes,
persistedActiveChatTypeId,
selectedChatType,
]);
const effectiveChatTypeId = effectiveChatType?.id ?? null;
const effectiveRegisteredChatType =
effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null;
const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
? canUseChatType(effectiveRegisteredChatType, userRoles)
: false;
const currentContext: ChatViewContext = {
pageId: currentPage.id,
pageTitle: currentPage.title,
topMenu: currentPage.topMenu,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
isStandaloneMode: isStandaloneDisplayMode(),
pageVisibilityState:
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
chatTypeId: effectiveChatType?.id ?? null,
chatTypeLabel: effectiveChatType?.name ?? '',
chatTypeDescription: effectiveChatType?.description ?? '',
};
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
sessionId: activeSessionId,
currentContext,
@@ -1410,21 +1471,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
activeView,
hasAccess,
});
const previewItems = useMemo(
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
[messages],
);
const isTabletAppLayout = isMobileViewport;
const chatMessages = useMemo(
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
[messages],
);
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
const activeRuntimeStatus = useMemo(
() => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
[runtimeSnapshot, activeSessionId],
@@ -1576,7 +1622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
activeSessionId,
activeView,
previewItems,
selectedChatTypeId: selectedChatType?.id ?? null,
selectedChatTypeId,
composerRef,
setActiveSystemStatus,
setComposerAttachments,
@@ -1634,10 +1680,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
}, [activePreview, messageApi]);
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
const canSearchActivePreview =
Boolean(activePreview) &&
!isPreviewLoading &&
!previewError.trim() &&
!(isActivePreviewHtml && isHtmlPreviewMode) &&
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
const resetActivePreviewSearchState = useCallback(() => {
@@ -1673,6 +1722,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
clearActivePreviewSearchSelection();
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
useEffect(() => {
setIsHtmlPreviewMode(false);
}, [activePreview?.id, isPreviewModalOpen]);
useEffect(() => {
resetActivePreviewSearchState();
}, [previewFindQuery, resetActivePreviewSearchState]);
@@ -2254,25 +2307,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, []);
useEffect(() => {
const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId;
lastChatTypeSessionIdRef.current = activeSessionId;
if (activeSessionId) {
if (hasSessionChanged) {
const lastUsedChatTypeId =
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
if (!activeConversation) {
return;
}
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
if (selectedChatTypeId !== lastUsedChatTypeId) {
setSelectedChatTypeId(lastUsedChatTypeId);
}
return;
}
const persistedChatTypeId =
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
const defaultChatTypeId = availableChatTypes[0]?.id ?? null;
if (selectedChatTypeId !== defaultChatTypeId) {
setSelectedChatTypeId(defaultChatTypeId);
if (persistedChatTypeId) {
if (selectedChatTypeId !== persistedChatTypeId) {
setSelectedChatTypeId(persistedChatTypeId);
}
return;
}
@@ -2283,34 +2328,38 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
useEffect(() => {
if (!activeSessionId || !selectedChatTypeId) {
if (!activeSessionId || !selectedChatTypeId || !selectedChatType) {
return;
}
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
setConversationItems((previous) =>
previous.map((item) =>
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
? { ...item, lastChatTypeId: selectedChatTypeId }
: item,
),
);
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
if (currentLastChatTypeId === selectedChatTypeId) {
const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
if (currentChatTypeId) {
return;
}
void chatGateway.updateConversation(activeSessionId, {
chatTypeId: selectedChatTypeId,
lastChatTypeId: selectedChatTypeId,
contextLabel: selectedChatType.name,
contextDescription: selectedChatType.description,
}).then((item) => {
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
);
}).catch(() => {
// Ignore background sync failures and keep local in-memory fallback.
});
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
}, [
activeConversation?.chatTypeId,
activeConversation?.lastChatTypeId,
activeSessionId,
selectedChatType,
selectedChatTypeId,
setConversationItems,
]);
useEffect(() => {
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
@@ -2600,7 +2649,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
draft,
composerAttachments,
isComposerAttachmentUploading,
selectedChatType,
selectedChatType: effectiveChatType
? {
id: effectiveChatType.id,
name: effectiveChatType.name,
description: effectiveChatType.description,
}
: null,
socketRef,
composerRef,
messagesRef,
@@ -2614,7 +2669,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsSystemStatusPending,
setShowScrollToBottom,
setPendingContextConfirm,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
@@ -2924,7 +2978,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
isPullToLoadArmed={isPullToLoadArmed}
pullToLoadDistance={pullToLoadDistance}
requestStateMap={activeRequestMap}
selectedChatTypeId={selectedChatType?.id ?? null}
selectedChatTypeId={effectiveChatTypeId}
queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
requestId: item.requestId,
order: index + 1,
@@ -2933,7 +2987,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeOptions={chatTypeOptions}
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
isResourceStripOpen={isResourceStripOpen}
isComposerDisabled={!selectedChatType}
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())}
isComposerAttachmentUploading={isComposerAttachmentUploading}
onViewportScroll={handleViewportScroll}
onViewportTouchEnd={handleViewportTouchEnd}
@@ -2944,7 +2999,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
onRemoveComposerAttachment={(attachmentId) => {
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
}}
onSelectChatType={setSelectedChatTypeId}
onSelectChatType={(nextChatTypeId) => {
if (activeConversation?.chatTypeId?.trim()) {
return;
}
setSelectedChatTypeId(nextChatTypeId);
}}
onSend={handleSend}
onSendImmediate={handleSendImmediate}
onClearDraft={() => {
@@ -3061,6 +3122,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<div className="app-chat-panel__preview-modal-title">
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
<Space size={4} wrap>
{isActivePreviewHtml ? (
<Button
type={isHtmlPreviewMode ? 'default' : 'text'}
aria-label={isHtmlPreviewMode ? 'HTML 소스 보기' : 'HTML 미리보기'}
icon={<EyeOutlined />}
onClick={() => {
setIsHtmlPreviewMode((current) => !current);
}}
>
{isHtmlPreviewMode ? '소스' : '미리보기'}
</Button>
) : null}
{canSearchActivePreview ? (
<Button
type={isPreviewFindOpen ? 'default' : 'text'}
@@ -3141,6 +3214,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={undefined}
renderHtmlAsFrame={isActivePreviewHtml && isHtmlPreviewMode}
/>
</div>
</div>

View File

@@ -961,16 +961,28 @@ export function MainHeader({
const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
const hasBuildRequiredUpdate =
Boolean(testServerStatus?.buildRequired) ||
Boolean(prodServerStatus?.buildRequired) ||
Boolean(workServerStatus?.buildRequired);
const totalAutomationShortcutCount =
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
const settingsStatusClassName =
totalPendingUpdateCount >= 2
hasBuildRequiredUpdate
? 'app-header__status-dot--inactive'
: totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive'
: totalPendingUpdateCount === 1
? 'app-header__status-dot--warning'
: 'app-header__status-dot--active';
const settingsStatusLabel =
totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태';
hasBuildRequiredUpdate
? '커밋 미반영 업데이트 존재'
: totalPendingUpdateCount >= 2
? '모든 업데이트 존재'
: totalPendingUpdateCount === 1
? '업데이트 1건 존재'
: '최신 상태';
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
@@ -2832,7 +2844,7 @@ export function MainHeader({
<>
<button
type="button"
className={connectionIndicatorClassName}
className={`${connectionIndicatorClassName} app-header__connection-indicator--labelled`}
aria-label={chatConnectionLabel}
title={chatConnectionLabel}
onClick={() => {
@@ -2843,6 +2855,12 @@ export function MainHeader({
<ApiOutlined />
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
</span>
<span className="app-header__connection-copy">
<span className="app-header__connection-title"></span>
<span className="app-header__connection-meta">
{hasPendingRuntimeWork ? `실행 ${runningRuntimeCount} · 대기 ${queuedRuntimeCount}` : '바로 열기'}
</span>
</span>
{runningRuntimeCount > 0 ? (
<span
className={connectionCountBadgeClassName}

View File

@@ -83,6 +83,14 @@
cursor: pointer;
}
.app-header__connection-indicator--labelled {
justify-content: flex-start;
gap: 8px;
width: auto;
min-width: 124px;
padding: 0 12px 0 10px;
}
.app-header__connection-indicator:hover {
background: #f3f7ff;
}
@@ -140,6 +148,28 @@
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
}
.app-header__connection-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
min-width: 0;
line-height: 1.1;
}
.app-header__connection-title {
font-size: 13px;
font-weight: 700;
color: #182230;
white-space: nowrap;
}
.app-header__connection-meta {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
@keyframes app-header-connection-badge-pulse {
0%,
100% {
@@ -487,6 +517,12 @@
width: 100%;
}
.app-main-layout:has(.chat-type-management-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
}
@media (max-width: 720px) {
html,
body,
@@ -734,6 +770,17 @@
height: 32px;
}
.app-header__connection-indicator--labelled {
min-width: 32px;
width: 32px;
padding: 0;
justify-content: center;
}
.app-header__connection-copy {
display: none;
}
.app-header__runtime-summary {
gap: 8px;
}
@@ -764,6 +811,11 @@
gap: 8px;
}
.app-main-layout:has(.chat-type-management-page) {
padding: 0;
gap: 0;
}
.app-main-window-layer {
inset: 8px;
}

View File

@@ -47,7 +47,7 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',

View File

@@ -23,10 +23,6 @@ export type ChatTypeInput = {
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: '게스트',
'token-user': '토큰 사용자',
@@ -50,6 +46,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
@@ -218,68 +223,6 @@ async function requestChatTypes<T>(init?: RequestInit) {
}
}
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 null;
}
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
if (!Array.isArray(parsed)) {
return null;
}
const deletedIds = readLegacyDeletedChatTypeIds();
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
return normalized;
} catch {
return null;
}
}
function clearLegacyChatTypeStorage() {
if (typeof window === 'undefined') {
return;
}
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',
@@ -300,7 +243,6 @@ async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
});
emitChatTypesChange();
clearLegacyChatTypeStorage();
return sanitizeChatTypes(response.chatTypes);
}
@@ -333,7 +275,17 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
return sanitizeChatTypes(chatTypes);
}
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
return sanitizeChatTypes(
chatTypes.map((item) =>
item.id === normalizedId
? {
...item,
enabled: false,
updatedAt: new Date().toISOString(),
}
: item,
),
);
}
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
@@ -359,13 +311,7 @@ export function useChatTypeRegistry() {
try {
const serverChatTypes = await fetchChatTypesFromServer();
let resolvedChatTypes = serverChatTypes;
if (resolvedChatTypes == null) {
const legacyChatTypes = readLegacyChatTypes();
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
}
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
if (isMountedRef.current) {
setChatTypesState(resolvedChatTypes);

View File

@@ -18,6 +18,8 @@ import {
type ChatPreviewKind,
type ChatPreviewTarget,
} from '../../mainChatPanel/ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -35,12 +37,12 @@ type ConversationRoomPaneProps = {
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 = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
};
@@ -132,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl
}
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: ChatPreviewTarget[] = [];
@@ -220,12 +222,10 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.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();
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const visibleText = stripHiddenPreviewTags(previewSourceText);
return { visibleText, diffBlocks };
return { previewSourceText, visibleText, diffBlocks };
}
function isLikelyCollapsibleMessage(text: string) {
@@ -574,8 +574,8 @@ export function ConversationRoomPane({
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 { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');

View File

@@ -58,7 +58,6 @@ type UseConversationComposerControllerOptions = {
setIsSystemStatusPending: (value: boolean) => void;
setShowScrollToBottom: (value: boolean) => void;
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
upsertRequestItem: (request: ChatConversationRequest) => void;
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
@@ -95,7 +94,6 @@ export function useConversationComposerController({
setIsSystemStatusPending,
setShowScrollToBottom,
setPendingContextConfirm,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
@@ -181,8 +179,6 @@ export function useConversationComposerController({
failed: false,
};
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
@@ -302,7 +298,6 @@ export function useConversationComposerController({
setIsSystemStatusPending,
setMessages,
setShowScrollToBottom,
setStoredChatSessionLastTypeId,
shouldStickToBottomRef,
socketRef,
syncConversationPreviewForRequest,

View File

@@ -33,6 +33,7 @@ import {
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
@@ -83,7 +84,6 @@ type PreviewFetchError = Error & {
status?: number;
};
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]]';
@@ -92,6 +92,7 @@ const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
type MessageRenderPayload = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
};
@@ -169,6 +170,21 @@ function buildPreviewFileName(item: PreviewOption) {
}
}
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
switch (kind) {
case 'image':
case 'video':
case 'markdown':
case 'code':
case 'diff':
case 'document':
case 'pdf':
return kind;
default:
return 'file';
}
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = '';
@@ -199,7 +215,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
@@ -293,9 +309,11 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const visibleText = stripHiddenPreviewTags(previewSourceText);
return {
previewSourceText,
visibleText,
diffBlocks,
};
@@ -688,6 +706,7 @@ type ChatConversationViewProps = {
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
isComposerDisabled: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
onViewportScroll: () => void;
onViewportTouchEnd: () => void;
@@ -733,6 +752,7 @@ export function ChatConversationView({
previewItems,
isResourceStripOpen,
isComposerDisabled,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
onViewportScroll,
onViewportTouchEnd,
@@ -756,6 +776,7 @@ export function ChatConversationView({
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
@@ -1191,17 +1212,22 @@ export function ChatConversationView({
</label>
<div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => (
<button
key={item.id}
type="button"
className="app-chat-panel__resource-chip"
onClick={() => {
onOpenPreview(item.id);
}}
>
<span>{item.label}</span>
<span>{item.kind}</span>
</button>
<InlineMessagePreview
key={item.id}
target={{
label: item.label,
url: item.url,
kind: normalizePreviewOptionKind(item.kind),
}}
isExpanded={expandedResourcePreviewKey === item.id}
hasModalPreview
onOpenModalPreview={() => {
onOpenPreview(item.id, { fullscreen: true });
}}
onToggle={() => {
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
}}
/>
))}
</div>
</>
@@ -1248,13 +1274,13 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
@@ -1498,7 +1524,7 @@ export function ChatConversationView({
),
}))}
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
disabled={chatTypeOptions.length === 0}
disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked}
onChange={onSelectChatType}
/>
</div>

View File

@@ -203,6 +203,33 @@ function canRenderFramePreview(url: string) {
}
}
function buildHtmlFrameDocument(html: string, sourceUrl: string) {
const trimmed = html.trim();
if (!trimmed) {
return '<!doctype html><html><body></body></html>';
}
const baseHref = (() => {
try {
return new URL('.', sourceUrl).toString();
} catch {
return sourceUrl;
}
})();
const baseTag = `<base href="${baseHref}">`;
if (/<head(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<head(\s*[^>]*)>/i, (match) => `${match}${baseTag}`);
}
if (/<html(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<html(\s*[^>]*)>/i, (match) => `${match}<head>${baseTag}</head>`);
}
return `<!doctype html><html><head>${baseTag}</head><body>${trimmed}</body></html>`;
}
type ChatPreviewBodyProps = {
target: ChatPreviewTarget | null;
previewText: string;
@@ -210,6 +237,7 @@ type ChatPreviewBodyProps = {
previewError: string;
previewContentType?: string;
maxMarkdownBlocks?: number;
renderHtmlAsFrame?: boolean;
};
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
@@ -238,6 +266,7 @@ export function ChatPreviewBody({
previewError,
previewContentType,
maxMarkdownBlocks,
renderHtmlAsFrame = false,
}: ChatPreviewBodyProps) {
if (!target) {
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
@@ -307,6 +336,16 @@ export function ChatPreviewBody({
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {
return (
<iframe
title={target.label}
srcDoc={buildHtmlFrameDocument(previewText, target.url)}
className="app-chat-panel__preview-frame"
/>
);
}
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">

View File

@@ -0,0 +1,26 @@
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
export function extractAutoDetectedPreviewUrls(text: string) {
const normalized = String(text ?? '');
const urls: string[] = [];
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
const value = match[0]?.trim();
if (!value) {
continue;
}
const startIndex = match.index ?? -1;
const previousChar = startIndex > 0 ? normalized[startIndex - 1] : '';
// Ignore HTML closing tags like </div> that were being misread as same-origin routes.
if (previousChar === '<') {
continue;
}
urls.push(value);
}
return urls;
}

View File

@@ -1,18 +1,26 @@
import {
ArrowLeftOutlined,
DownOutlined,
CheckSquareOutlined,
CompressOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
ArrowsAltOutlined,
EyeOutlined,
ExpandOutlined,
FileTextOutlined,
LinkOutlined,
PaperClipOutlined,
PlayCircleOutlined,
PlusOutlined,
SaveOutlined,
ShrinkOutlined,
UpOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import type { ChangeEvent, RefObject } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
@@ -27,7 +35,7 @@ import {
setupBoard,
updateBoardPost,
} from './api';
import type { BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
@@ -36,15 +44,68 @@ const EMPTY_DRAFT: BoardDraft = {
id: null,
title: '',
content: '',
attachments: [],
automationType: 'none',
};
function createBoardAttachmentSessionId() {
const randomValue =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
return `board-draft-${randomValue}`;
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
});
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B';
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${Math.round(value)} B`;
}
function mergeBoardAttachments(current: BoardAttachment[], next: BoardAttachment[]) {
const merged = [...current];
const existingPaths = new Set(current.map((item) => item.path));
next.forEach((item) => {
if (existingPaths.has(item.path)) {
return;
}
existingPaths.add(item.path);
merged.push(item);
});
return merged;
}
function resolveBoardAttachmentSessionId(
draftId: number | null,
draftAttachmentSessionIdRef: RefObject<string>,
) {
if (draftId) {
return `board-post-${draftId}`;
}
return draftAttachmentSessionIdRef.current;
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
@@ -122,6 +183,8 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry();
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const draftAttachmentSessionIdRef = useRef<string>(createBoardAttachmentSessionId());
const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -129,34 +192,15 @@ export function BoardPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [attachmentUploading, setAttachmentUploading] = useState(false);
const [automationReceiving, setAutomationReceiving] = useState(false);
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [contentExpanded, setContentExpanded] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileDetailOpen(false);
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [attachmentsExpanded, setAttachmentsExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -193,11 +237,32 @@ export function BoardPage() {
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
setAttachmentsExpanded(!mediaQuery.matches);
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
);
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
const isDraftLocked = automationReceived;
const draftDirty = Boolean(
@@ -218,6 +283,7 @@ export function BoardPage() {
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const isPaneMaximized = maximizedPane !== 'none';
const receivableIds = useMemo(
() =>
items
@@ -236,6 +302,7 @@ export function BoardPage() {
id: selectedItem.id,
title: selectedItem.title,
content: selectedItem.content,
attachments: selectedItem.attachments,
automationType: selectedItem.automationType,
});
setAutomationReceiveError(null);
@@ -252,10 +319,82 @@ export function BoardPage() {
}, [items]);
const handleCreateDraft = () => {
draftAttachmentSessionIdRef.current = createBoardAttachmentSessionId();
setSelectedId(null);
setDraft(EMPTY_DRAFT);
setAutomationReceiveError(null);
setMobileDetailOpen(isMobileViewport);
setMaximizedPane('none');
setMobileView('edit');
setDetailMode('detail');
};
const handleOpenDetail = (itemId: number) => {
setSelectedId(itemId);
setAutomationReceiveError(null);
setMaximizedPane('none');
setMobileView('edit');
setDetailMode('detail');
};
const handleCloseDetail = () => {
setAutomationReceiveError(null);
setMaximizedPane('none');
setDetailMode('list');
};
const handleAttachmentFilesPicked = async (files: File[]) => {
if (files.length === 0 || attachmentUploading || isDraftLocked) {
return;
}
setAttachmentUploading(true);
try {
const sessionId = resolveBoardAttachmentSessionId(draft.id, draftAttachmentSessionIdRef);
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
const uploadedItems: BoardAttachment[] = [];
const failedFileNames: string[] = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
});
if (uploadedItems.length > 0) {
setDraft((previous) => ({
...previous,
attachments: mergeBoardAttachments(previous.attachments, uploadedItems),
}));
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 추가했습니다.`);
}
if (failedFileNames.length > 0) {
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
}
} finally {
setAttachmentUploading(false);
}
};
const handleAttachmentInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void handleAttachmentFilesPicked(files);
};
const handleRemoveAttachment = (attachmentId: string) => {
if (isDraftLocked) {
return;
}
setDraft((previous) => ({
...previous,
attachments: previous.attachments.filter((attachment) => attachment.id !== attachmentId),
}));
};
const handleSave = async () => {
@@ -293,7 +432,7 @@ export function BoardPage() {
return [savedItem, ...filtered];
});
setSelectedId(savedItem.id);
setMobileDetailOpen(isMobileViewport);
setDetailMode('detail');
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
@@ -315,7 +454,7 @@ export function BoardPage() {
setItems((previous) => previous.filter((item) => item.id !== draft.id));
setSelectedId((previous) => (previous === draft.id ? null : previous));
setDraft(EMPTY_DRAFT);
setMobileDetailOpen(false);
setDetailMode('list');
messageApi.success('게시글을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
@@ -441,52 +580,56 @@ export function BoardPage() {
};
return (
<Space direction="vertical" size={16} className="board-page">
<div
className={`board-page${detailMode === 'detail' ? ' board-page--detail' : ''}${
isPaneMaximized ? ' board-page--pane-maximized' : ''
}`}
>
{contextHolder}
<Card className="board-page__card" bordered={false}>
<Flex justify="space-between" align="center" gap={16} wrap>
<div>
<Title level={4} className="board-page__title">
Plan
</Title>
<Paragraph className="board-page__copy">
DB에 .
</Paragraph>
</div>
<Space wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
disabled={isDraftLocked}
onClick={() => {
void handleSave();
}}
>
</Button>
</Space>
</Flex>
</Card>
<input
ref={attachmentInputRef}
type="file"
multiple
className="board-page__hidden-file-input"
onChange={handleAttachmentInputChange}
/>
<Space direction="vertical" size={16} className="board-page__stack">
{detailMode === 'list' ? (
<Card className="board-page__card board-page__overview-card" bordered={false}>
<Flex justify="space-between" align="center" gap={16} wrap>
<div>
<Title level={4} className="board-page__title">
</Title>
<Paragraph className="board-page__copy">
, , .
</Paragraph>
</div>
<Space wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
</Space>
</Flex>
</Card>
) : null}
{errorMessage ? (
{errorMessage && detailMode === 'list' ? (
<Card className="board-page__card" bordered={false}>
<Text type="danger">{errorMessage}</Text>
</Card>
) : null}
<div className="board-page__grid">
{detailMode === 'list' ? (
<Card
title={`게시글 목록 (${items.length})`}
className={`board-page__card board-page__list-card${
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
}`}
className="board-page__card board-page__list-card"
bordered={false}
extra={
<Space size={8} wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
{loading ? <Spin size="small" /> : null}
<Text type="secondary" className="board-page__bulk-count">
{checkedReceivableCount}
@@ -528,15 +671,12 @@ export function BoardPage() {
<List
dataSource={items}
renderItem={(item) => (
<List.Item
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
onClick={() => {
setSelectedId(item.id);
if (isMobileViewport) {
setMobileDetailOpen(true);
}
}}
>
<List.Item
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
onClick={() => {
handleOpenDetail(item.id);
}}
>
<List.Item.Meta
avatar={
<Checkbox
@@ -562,6 +702,7 @@ export function BoardPage() {
</Flex>
<Space size={6} wrap>
{item.id === dirtyDraftId ? <Tag color="warning"> </Tag> : null}
{item.attachments.length ? <Tag color="blue"> {item.attachments.length}</Tag> : null}
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
</Tag>
@@ -584,151 +725,335 @@ export function BoardPage() {
<Empty description="등록된 게시글이 없습니다." />
)}
</Card>
<div
className={`board-page__editor-column${
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
}`}
>
) : (
<div className="board-page__editor-column">
<Card
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
className="board-page__card board-page__editor-card"
className={`board-page__card board-page__editor-card${isPaneMaximized ? ' board-page__editor-card--pane-maximized' : ''}`}
bordered={false}
extra={
<Space wrap>
{isMobileViewport && mobileDetailOpen ? (
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
setMobileDetailOpen(false);
}}
>
</Button>
) : null}
<Space wrap className="board-page__header-actions">
<Button icon={<ArrowLeftOutlined />} aria-label="목록으로" title="목록으로" onClick={handleCloseDetail} />
<Button icon={<PlusOutlined />} onClick={handleCreateDraft} aria-label="새 글" title="새 글" />
<Button
type="primary"
icon={<SaveOutlined />}
aria-label="저장"
title="저장"
loading={saving}
disabled={isDraftLocked}
onClick={() => {
void handleSave();
}}
/>
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
{draft.id && selectedItem?.automationPlanItemId ? (
<Button
icon={<LinkOutlined />}
aria-label="연결 자동화 열기"
title="연결 자동화 열기"
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
target="_blank"
rel="noreferrer"
>
</Button>
/>
) : null}
{draft.id ? (
<Button
icon={<PlayCircleOutlined />}
aria-label={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
title={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
loading={automationReceiving}
disabled={automationReceived && !automationReceiveError}
onClick={() => {
void handleAutomationReceive();
}}
>
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
</Button>
) : null}
{draft.id ? (
<Button
danger
icon={<DeleteOutlined />}
loading={deleting}
disabled={isDraftLocked}
onClick={() => {
void handleDelete();
}}
>
</Button>
/>
) : null}
<Button
danger
icon={<DeleteOutlined />}
aria-label="삭제"
title="삭제"
loading={deleting}
disabled={!draft.id || isDraftLocked}
onClick={() => {
void handleDelete();
}}
/>
</Space>
}
>
<Space direction="vertical" size={16} className="board-page__editor">
<Input
size="large"
placeholder="제목을 입력하세요"
value={draft.title}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
title: event.target.value,
}));
}}
/>
<Flex gap={8} wrap>
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
</Flex>
<div className="board-page__automation-field">
<Text strong> </Text>
{automationReceived ? (
<div className="board-page__automation-readonly" aria-readonly="true">
<Text>{automationTypeLabel}</Text>
<Tag color="processing"> </Tag>
<div className="board-page__editor">
{errorMessage ? <Text type="danger">{errorMessage}</Text> : null}
<div className="board-page__editor-scroll">
<div className={`board-page__meta-stack${isPaneMaximized ? ' board-page__meta-stack--hidden' : ''}`}>
<div className="board-page__hero">
<div className="board-page__hero-main">
<div className="board-page__field-label-row">
<Text strong> </Text>
<Flex gap={8} wrap>
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
</Flex>
</div>
<Input
size="large"
placeholder="예: 작업요청 입력 폼을 전면 개편하고 첨부 자동 전달 연결"
value={draft.title}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
title: event.target.value,
}));
}}
/>
</div>
<div className="board-page__hero-side">
<div className="board-page__automation-field">
<div className="board-page__field-label-row">
<Text strong> </Text>
{automationReceived ? <Tag color="processing"> </Tag> : null}
</div>
{automationReceived ? (
<div className="board-page__automation-readonly" aria-readonly="true">
<Text>{automationTypeLabel}</Text>
</div>
) : (
<Select
className="board-page__automation-select"
value={draft.automationType}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
)}
</div>
</div>
</div>
) : (
<Select
className="board-page__automation-select"
value={draft.automationType}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
)}
<div className="board-page__attachment-panel">
<Flex justify="space-between" align="center" gap={12} wrap>
<div>
<div className="board-page__field-label-row">
<Text strong> </Text>
<Tag color={draft.attachments.length ? 'blue' : 'default'}>
{draft.attachments.length}
</Tag>
</div>
<Text type="secondary"> .</Text>
</div>
<Space size={8}>
<Button
type="text"
icon={attachmentsExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
title={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
onClick={() => {
setAttachmentsExpanded((current) => !current);
}}
/>
<Button
icon={<UploadOutlined />}
aria-label="파일 추가"
title="파일 추가"
loading={attachmentUploading}
disabled={isDraftLocked}
onClick={() => {
attachmentInputRef.current?.click();
}}
/>
</Space>
</Flex>
{attachmentsExpanded ? draft.attachments.length ? (
<div className="board-page__attachment-grid">
{draft.attachments.map((attachment) => (
<div key={attachment.id} className="board-page__attachment-card">
<Flex justify="space-between" align="start" gap={12}>
<Flex vertical gap={6} className="board-page__attachment-copy">
<Space size={8} wrap>
<PaperClipOutlined className="board-page__attachment-icon" />
<Text strong ellipsis={{ tooltip: attachment.name }}>
{attachment.name}
</Text>
</Space>
<Text type="secondary">{formatBytes(attachment.size)}</Text>
<Text type="secondary" className="board-page__attachment-path">
{attachment.path}
</Text>
</Flex>
<Space size={6}>
<Button
size="small"
icon={<LinkOutlined />}
href={attachment.publicUrl}
target="_blank"
rel="noreferrer"
/>
<Button
size="small"
icon={<CloseOutlined />}
disabled={isDraftLocked}
onClick={() => {
handleRemoveAttachment(attachment.id);
}}
/>
</Space>
</Flex>
</div>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="첨부 파일이 없습니다." />
) : null}
</div>
</div>
<div className="board-page__markdown-field">
<Text strong className={`board-page__field-label${isPaneMaximized ? ' board-page__field-label--hidden' : ''}`}>
</Text>
<div className="board-page__markdown-editor">
<div className="board-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="board-page__mobile-toggle"
options={[
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap className="board-page__desktop-toolbar">
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
/>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
/>
</Space>
)}
</div>
<div
className={`board-page__preview-grid${
isPaneMaximized ? ' board-page__preview-grid--maximized' : ''
}`}
>
<div
className={`board-page__pane${
mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''
}${maximizedPane === 'preview' ? ' board-page__pane--desktop-hidden' : ''}`}
>
<div className="board-page__pane-header">
<Text type="secondary"></Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
/>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
/>
</Space>
</div>
<TextArea
value={draft.content}
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
content: event.target.value,
}));
}}
className="board-page__textarea"
/>
</div>
<div
className={`board-page__pane${
mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''
}${maximizedPane === 'edit' ? ' board-page__pane--desktop-hidden' : ''}`}
>
<div className="board-page__preview">
<div className="board-page__pane-header">
<Text type="secondary"></Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
/>
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
/>
</Space>
</div>
<div className="board-page__preview-content">
{draft.content.trim() ? (
<MarkdownPreviewContent content={draft.content} />
) : (
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
</div>
</div>
{isDraftLocked ? (
<Text type="secondary"> .</Text>
) : null}
</div>
</div>
</div>
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
{contentExpanded ? (
<Flex justify="space-between" align="center" gap={12} className="board-page__editor-toolbar">
<Text strong> </Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
>
</Button>
<Button
size="small"
icon={<CompressOutlined />}
aria-label="본문 최대화 해제"
onClick={() => {
setContentExpanded(false);
}}
>
</Button>
</Space>
</Flex>
) : null}
<Segmented
className="board-page__mobile-toggle"
options={[
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
}}
/>
<Flex justify="space-between" align="center" gap={8}>
<Text type="secondary"></Text>
{isPaneMaximized ? (
<div className="board-page__floating-toolbar">
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
@@ -737,60 +1062,22 @@ export function BoardPage() {
}}
/>
<Button
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
size="small"
icon={<ShrinkOutlined />}
aria-label="편집 보기로 복귀"
title="편집 보기로 복귀"
onClick={() => {
setContentExpanded((previous) => !previous);
setMaximizedPane('none');
}}
/>
</Space>
</Flex>
<div
className={`board-page__preview-grid${contentExpanded ? ' board-page__preview-grid--expanded' : ''}`}
>
<div
className={`board-page__pane${mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''}${
contentExpanded ? ' board-page__pane--expanded' : ''
}`}
>
<TextArea
value={draft.content}
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
content: event.target.value,
}));
}}
className={`board-page__textarea${contentExpanded ? ' board-page__textarea--expanded' : ''}`}
/>
</div>
<div
className={`board-page__pane${mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''}${
contentExpanded ? ' board-page__pane--expanded' : ''
}`}
>
<div className={`board-page__preview${contentExpanded ? ' board-page__preview--expanded' : ''}`}>
<div className="board-page__preview-content">
{draft.content.trim() ? (
<MarkdownPreviewContent content={draft.content} />
) : (
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
</div>
</div>
{isDraftLocked ? (
<Text type="secondary"> .</Text>
) : null}
</div>
</Space>
) : null}
</div>
</Card>
</div>
</div>
</Space>
)}
</Space>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
class BoardApiError extends Error {
status: number;
@@ -17,6 +17,37 @@ function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
return normalizeAutomationTypeId(value);
}
function normalizeBoardAttachment(item: unknown): BoardAttachment | null {
if (!item || typeof item !== 'object') {
return null;
}
const candidate = item as Partial<BoardAttachment>;
const id = String(candidate.id ?? '').trim();
const path = String(candidate.path ?? '').trim();
if (!id || !path) {
return null;
}
return {
id,
name: String(candidate.name ?? '').trim() || path.split('/').pop() || '첨부 파일',
path,
publicUrl: String(candidate.publicUrl ?? '').trim() || path,
size: Math.max(0, Number(candidate.size ?? 0) || 0),
mimeType: String(candidate.mimeType ?? '').trim() || 'application/octet-stream',
};
}
function normalizeBoardPost(item: BoardPost): BoardPost {
return {
...item,
automationType: normalizeBoardAutomationType(item.automationType),
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
};
}
function resolveBoardApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
@@ -134,10 +165,7 @@ export async function setupBoard() {
export async function fetchBoardPosts() {
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
return response.items.map((item) => ({
...item,
automationType: normalizeBoardAutomationType(item.automationType),
}));
return response.items.map((item) => normalizeBoardPost(item));
}
export async function createBoardPost(draft: BoardDraft) {
@@ -146,14 +174,12 @@ export async function createBoardPost(draft: BoardDraft) {
body: JSON.stringify({
title: draft.title,
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
}),
});
return {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
return normalizeBoardPost(response.item);
}
export async function updateBoardPost(draft: BoardDraft) {
@@ -166,14 +192,12 @@ export async function updateBoardPost(draft: BoardDraft) {
body: JSON.stringify({
title: draft.title,
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
}),
});
return {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
return normalizeBoardPost(response.item);
}
export async function receiveBoardPostAutomation(id: number) {
@@ -188,10 +212,7 @@ export async function receiveBoardPostAutomation(id: number) {
});
return {
item: {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
},
item: normalizeBoardPost(response.item),
planItemId: response.planItemId,
alreadyReceived: response.alreadyReceived,
};

View File

@@ -1,10 +1,20 @@
export type BoardAutomationType = string;
export type BoardAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type BoardPost = {
id: number;
title: string;
content: string;
preview: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
automationPlanItemId: number | null;
automationReceivedAt: string | null;
@@ -16,5 +26,6 @@ export type BoardDraft = {
id: number | null;
title: string;
content: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
};

View File

@@ -1022,7 +1022,44 @@ button,
.board-page {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.board-page--detail {
container-type: inline-size;
}
.board-page__stack.ant-space,
.board-page__stack.ant-space > .ant-space-item {
width: 100%;
}
.board-page__stack.ant-space {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page--detail .board-page__stack.ant-space > .ant-space-item,
.board-page--detail .board-page__editor-column,
.board-page--detail .board-page__editor-card,
.board-page--detail .board-page__editor-card .ant-card-body,
.board-page--detail .board-page__editor,
.board-page--detail .board-page__editor-scroll,
.board-page--detail .board-page__markdown-field,
.board-page--detail .board-page__markdown-editor,
.board-page--detail .board-page__preview-grid,
.board-page--detail .board-page__pane,
.board-page--detail .board-page__preview {
flex: 1 1 auto;
min-height: 0;
}
.board-page__card {
@@ -1031,6 +1068,18 @@ button,
box-shadow: none;
}
.board-page .ant-card,
.board-page .ant-card-body,
.board-page__card .ant-card-body {
min-width: 0;
}
.board-page .ant-card,
.board-page__card {
display: flex;
flex-direction: column;
}
.board-page__title.ant-typography {
margin-bottom: 6px;
}
@@ -1039,17 +1088,12 @@ button,
margin-bottom: 0;
}
.board-page__grid {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.board-page__editor-column {
position: relative;
z-index: 2;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page__list-card .ant-card-body,
@@ -1057,6 +1101,16 @@ button,
min-width: 0;
}
.board-page__list-card .ant-card-head,
.board-page__editor-card .ant-card-head {
padding-inline: 20px;
}
.board-page__list-card .ant-card-body,
.board-page__editor-card .ant-card-body {
padding: 20px;
}
.board-page__list-card--mobile-hidden,
.board-page__editor-column--mobile-hidden {
display: none;
@@ -1065,7 +1119,18 @@ button,
.board-page__editor-card,
.board-page__editor-card .ant-card-body {
position: relative;
overflow: visible;
}
.board-page__editor-card .ant-card-body {
height: 100%;
min-height: calc(100vh - 240px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.board-page__editor-card--pane-maximized .ant-card-body {
padding-bottom: 10px;
}
.board-page__list-item {
@@ -1103,18 +1168,42 @@ button,
font-weight: 600;
}
.board-page__editor.ant-space {
.board-page__editor {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.board-page__editor.ant-space > .ant-space-item {
.board-page__editor-scroll {
width: 100%;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow: auto;
padding-bottom: 8px;
}
.board-page__editor.ant-space > .ant-space-item:last-child {
min-height: 0;
.board-page__header-actions {
align-items: center;
justify-content: flex-end;
width: 100%;
}
.board-page__meta-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.board-page__meta-stack--hidden {
display: none;
}
.board-page__automation-field {
@@ -1124,10 +1213,72 @@ button,
gap: 8px;
}
.board-page__automation-readonly {
.board-page__hero {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
gap: 16px;
align-items: end;
}
.board-page__hero-main,
.board-page__hero-side,
.board-page__attachment-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.board-page__field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.board-page__attachment-panel {
padding: 16px 18px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 248, 255, 0.98) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.88),
0 14px 32px rgba(15, 23, 42, 0.05);
}
.board-page__attachment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.board-page__attachment-card {
min-width: 0;
padding: 14px;
border: 1px solid rgba(22, 93, 255, 0.14);
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.board-page__attachment-copy {
min-width: 0;
flex: 1 1 auto;
}
.board-page__attachment-icon {
color: #1677ff;
}
.board-page__attachment-path {
word-break: break-all;
}
.board-page__automation-readonly {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
min-height: 40px;
padding: 9px 12px;
@@ -1170,29 +1321,47 @@ button,
display: none;
}
.board-page__editor-frame {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-height: 0;
.board-page__hidden-file-input {
display: none;
}
.board-page__editor-frame--expanded {
position: fixed;
inset: 0;
z-index: 1300;
.board-page__markdown-field {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: #fff;
gap: 6px;
overflow: hidden;
}
.board-page__field-label {
line-height: 1.2;
}
.board-page__field-label--hidden {
display: none;
}
.board-page__markdown-editor {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.board-page__editor-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.board-page__desktop-toolbar {
margin-left: auto;
}
.board-page__preview-grid {
@@ -1201,63 +1370,65 @@ button,
gap: 16px;
width: 100%;
min-width: 0;
min-height: 0;
}
.board-page__preview-grid--expanded {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.board-page__preview-grid--maximized {
grid-template-columns: minmax(0, 1fr);
}
.board-page__pane {
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.board-page__pane--expanded {
.board-page__pane--desktop-hidden {
display: none;
}
.board-page__pane-header {
display: flex;
flex: 1 1 0;
width: 100%;
min-height: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.board-page__textarea.ant-input {
flex: 1 1 auto;
height: 100%;
min-height: 520px;
font-family:
'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace;
line-height: 1.6;
resize: vertical;
}
.board-page__textarea--expanded.ant-input {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
resize: none;
}
.board-page__preview {
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 520px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
background: #ffffff;
padding: 18px;
overflow: auto;
overflow: hidden;
}
.board-page__preview-content {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page__preview--expanded {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
min-width: 0;
overflow: auto;
}
.board-page__loading {
@@ -1266,6 +1437,16 @@ button,
min-height: 220px;
}
.board-page__floating-toolbar {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
justify-content: flex-end;
padding-top: 8px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.96) 28%);
}
.release-pending-main-modal .ant-modal-content {
border-radius: 24px;
}
@@ -1493,6 +1674,48 @@ button,
}
@media (max-width: 960px) {
.board-page {
flex: 1 1 auto;
min-height: 0;
}
.board-page .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.board-page--detail .board-page__editor-card .ant-card-body {
min-height: 0;
}
.board-page__overview-card {
display: none;
}
.board-page__list-card .ant-card-head,
.board-page__editor-card .ant-card-head {
min-height: 48px;
padding-inline: 10px;
}
.board-page__list-card .ant-card-head-title,
.board-page__list-card .ant-card-extra,
.board-page__editor-card .ant-card-head-title,
.board-page__editor-card .ant-card-extra,
.board-page__list-card .ant-card-body,
.board-page__editor-card .ant-card-body {
padding: 7px 10px;
}
.board-page__list-card .ant-card-head-title,
.board-page__list-card .ant-card-extra,
.board-page__editor-card .ant-card-head-title,
.board-page__editor-card .ant-card-extra {
padding-top: 6px;
padding-bottom: 6px;
}
.history-page__filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1505,10 +1728,25 @@ button,
grid-template-columns: 1fr;
}
.board-page__grid {
.board-page__hero {
grid-template-columns: 1fr;
}
.board-page__attachment-grid {
grid-template-columns: 1fr;
}
.board-page__editor-column,
.board-page__editor,
.board-page__editor-scroll,
.board-page__markdown-field,
.board-page__markdown-editor,
.board-page__preview-grid,
.board-page__pane,
.board-page__preview {
min-height: 0;
}
.history-page__list-card .ant-card-body,
.history-page__detail-card .ant-card-body,
.chat-source-changes-page__list-card .ant-card-body,
@@ -1518,13 +1756,7 @@ button,
.board-page__textarea.ant-input,
.board-page__preview {
min-height: 360px;
}
.board-page__textarea--expanded.ant-input,
.board-page__preview--expanded {
min-height: calc(100vh - 148px);
height: calc(100vh - 148px);
min-height: 0;
}
.release-pending-main-modal .ant-modal {
@@ -1554,20 +1786,28 @@ button,
grid-template-columns: 1fr;
}
.board-page__grid {
grid-template-columns: 1fr;
}
.board-page__mobile-toggle {
display: inline-flex;
}
.board-page__preview-grid {
grid-template-columns: 1fr;
.board-page__editor-toolbar {
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
}
.board-page__editor-frame--expanded {
padding: 16px;
.board-page__desktop-toolbar {
display: none;
}
.board-page__field-label-row {
align-items: flex-start;
flex-direction: column;
}
.board-page__preview-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.board-page__automation-readonly {
@@ -1579,6 +1819,36 @@ button,
display: none;
}
.board-page__textarea.ant-input {
height: 100% !important;
min-height: 0;
}
.board-page__textarea.ant-input textarea {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
overflow: auto !important;
resize: none;
}
.board-page__preview {
padding: 8px 10px;
}
.board-page__preview-content {
min-height: 0;
}
.board-page__header-actions {
gap: 4px;
}
.board-page__pane-header .ant-space {
flex-wrap: wrap;
justify-content: flex-end;
}
.release-review-page__toolbar {
align-items: stretch;
}