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

View File

@@ -4,7 +4,7 @@
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다. 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 기록과 상세 상단 상태 영역에 함께 표기합니다. 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)}" PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}" RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}" 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_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}" RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}" RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}" 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_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}" 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") RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then 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 exit 1
fi fi
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true) if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
if [ -n "$RUNNER_PIDS" ]; then chmod +x "$SUPERVISOR_SCRIPT"
kill $RUNNER_PIDS || true 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 sleep 1
fi fi
setsid env \ setsid env \
PROJECT_ROOT="$PROJECT_ROOT" \
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \ SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \ SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \ SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \ 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" echo "server-command-runner restart requested"

View File

@@ -4,6 +4,21 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
cd "$REPO_ROOT" 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, id serial primary key,
title varchar(200) not null, title varchar(200) not null,
content text not null, content text not null,
attachments_json text not null default '[]',
automation_plan_item_id integer null, automation_plan_item_id integer null,
automation_received_at timestamptz null, automation_received_at timestamptz null,
created_at timestamptz not null default now(), 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 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: ports:
- '127.0.0.1:3100:3100' - '127.0.0.1:3100:3100'
volumes: volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project - ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex - ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro - ../../../scripts:/workspace/repo-scripts:ro
@@ -50,3 +52,6 @@ services:
networks: networks:
work-backend: work-backend:
name: 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, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', 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() { async function ensureAppConfigTable() {

View File

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

View File

@@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => { test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
assert.equal( assert.equal(
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', { buildBoardPostPlanNote(
name: '자동화 메모', ' 알림 개선 ',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', '본문 첫 줄\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', () => { test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
assert.equal( assert.equal(
buildBoardPostPlanNote('작업', '본문', { buildBoardPostPlanNote(
name: '빈 context 유형', '작업',
description: ' ', '본문',
}), [],
{
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', () => { test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').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({ export const boardPostPayloadSchema = z.object({
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
content: z.string().min(1).max(200000), 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')), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
}); });
@@ -25,6 +35,7 @@ export type BoardPostItem = {
title: string; title: string;
content: string; content: string;
preview: string; preview: string;
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'];
automationType: z.infer<typeof boardPostPayloadSchema>['automationType']; automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
automationPlanItemId: number | null; automationPlanItemId: number | null;
automationReceivedAt: string | null; automationReceivedAt: string | null;
@@ -57,12 +68,22 @@ function createPreview(content: string) {
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem { function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
const content = String(row.content ?? ''); 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 { return {
id: Number(row.id ?? 0), id: Number(row.id ?? 0),
title: String(row.title ?? ''), title: String(row.title ?? ''),
content, content,
preview: createPreview(content), preview: createPreview(content),
attachments,
automationType: resolveStoredAutomationTypeId(row), automationType: resolveStoredAutomationTypeId(row),
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
? null ? null
@@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
return Boolean(row.automation_received_at || row.automation_plan_item_id); 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 normalizedTitle = title.trim();
const normalizedContent = content.trim(); const normalizedContent = content.trim();
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim(); const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
@@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio
'', '',
'## 요청 본문', '## 요청 본문',
normalizedContent, normalizedContent,
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
] ]
.filter((line): line is string => line !== null) .filter((line): line is string => line !== null)
.join('\n'); .join('\n');
@@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() {
['content', (table) => table.text('content').notNullable().defaultTo('')], ['content', (table) => table.text('content').notNullable().defaultTo('')],
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], ['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_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).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())], ['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({ const insertQuery = db(BOARD_POSTS_TABLE).insert({
title: parsedPayload.title, title: parsedPayload.title,
content: parsedPayload.content, content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
created_at: db.fn.now(), created_at: db.fn.now(),
@@ -276,11 +326,12 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim(); const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim(); const content = String(currentRow.content ?? '').trim();
const attachments = mapBoardPostRow(currentRow).attachments;
const workId = `board-post-${id}`; const workId = `board-post-${id}`;
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type); const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
const insertQuery = trx(PLAN_TABLE).insert({ const insertQuery = trx(PLAN_TABLE).insert({
work_id: workId, work_id: workId,
note: buildBoardPostPlanNote(title, content, automationType), note: buildBoardPostPlanNote(title, content, attachments, automationType),
automation_type: normalizePlanAutomationType(currentRow.automation_type), automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type, automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
status: '등록', status: '등록',
@@ -344,6 +395,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
.update({ .update({
title: parsedPayload.title, title: parsedPayload.title,
content: parsedPayload.content, content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
updated_at: trx.fn.now(), updated_at: trx.fn.now(),

View File

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

View File

@@ -8,6 +8,7 @@ import {
collectOfflineNotificationClientIds, collectOfflineNotificationClientIds,
createActivityLogMessage, createActivityLogMessage,
extractDiffCodeBlocks, extractDiffCodeBlocks,
extractCodexStreamText,
fitActivityLogLines, fitActivityLogLines,
isAutomationRegistrationCountRequest, isAutomationRegistrationCountRequest,
resolveResponseTimestamp, 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', () => { 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'); 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)); 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>) { export function extractCodexStreamText(parsed: Record<string, unknown>) {
const type = typeof parsed.type === 'string' ? parsed.type : ''; const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') { if (type === 'item.completed') {
const completedText = collectCodexTextFragments(parsed.item).join(''); const completedText = extractCompletedAgentMessageText(parsed.item);
return { return {
type, type,
completedText, completedText,
@@ -3186,9 +3200,19 @@ export class ChatService {
}); });
const progressMessages = buildProgressMessages(request.text); 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(() => { let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)]; 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); chatRuntimeService.appendLog(request.requestId, nextMessage);
appendActivityLine(`# 진행: ${nextMessage}`); appendActivityLine(`# 진행: ${nextMessage}`);
@@ -3199,6 +3223,8 @@ export class ChatService {
if (progressIndex < progressMessages.length - 1) { if (progressIndex < progressMessages.length - 1) {
progressIndex += 1; progressIndex += 1;
} else {
stopProgressTimer();
} }
}, 2200); }, 2200);

View File

@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
const createdPost = await createBoardPost({ const createdPost = await createBoardPost({
title: buildErrorLogBoardPostTitle(candidate), title: buildErrorLogBoardPostTitle(candidate),
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [],
automationType: 'none', 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'), Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'),
); );
const STREAM_CAPTURE_LIMIT = 256 * 1024; 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 = [ const CODEX_HOME_RUNTIME_PATHS = [
'auth.json', 'auth.json',
'config.toml', 'config.toml',
@@ -342,12 +350,24 @@ function collectCodexTextFragments(value) {
return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); 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) { function extractCodexStreamText(parsed) {
const type = typeof parsed.type === 'string' ? parsed.type : ''; const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') { if (type === 'item.completed') {
return { return {
completedText: collectCodexTextFragments(parsed.item).join(''), completedText: extractCompletedAgentMessageText(parsed.item),
deltaText: '', deltaText: '',
}; };
} }
@@ -504,6 +524,9 @@ async function runCodexLiveExecution(payload, response) {
let jsonLineBuffer = ''; let jsonLineBuffer = '';
let completedText = ''; let completedText = '';
let responseClosed = false; let responseClosed = false;
let idleTimer = null;
let executionTimer = null;
let terminationRequested = false;
response.writeHead(200, { response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8', '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); 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) => { const handleCodexJsonLine = (line) => {
let parsed; let parsed;
@@ -552,6 +637,7 @@ async function runCodexLiveExecution(payload, response) {
const activityLog = extractCodexActivityLog(parsed); const activityLog = extractCodexActivityLog(parsed);
if (activityLog) { if (activityLog) {
refreshIdleTimer();
sendJsonLine(response, { sendJsonLine(response, {
type: 'activity', type: 'activity',
line: activityLog, line: activityLog,
@@ -561,6 +647,7 @@ async function runCodexLiveExecution(payload, response) {
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed); const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
if (nextCompletedText) { if (nextCompletedText) {
refreshIdleTimer();
completedText = nextCompletedText; completedText = nextCompletedText;
sendJsonLine(response, { sendJsonLine(response, {
type: 'completed', type: 'completed',
@@ -570,6 +657,7 @@ async function runCodexLiveExecution(payload, response) {
} }
if (deltaText) { if (deltaText) {
refreshIdleTimer();
sendJsonLine(response, { sendJsonLine(response, {
type: 'delta', type: 'delta',
text: deltaText, text: deltaText,
@@ -582,9 +670,11 @@ async function runCodexLiveExecution(payload, response) {
response.on('close', () => { response.on('close', () => {
responseClosed = true; responseClosed = true;
clearExecutionTimers();
}); });
child.stdout?.on('data', (chunk) => { child.stdout?.on('data', (chunk) => {
refreshIdleTimer();
const text = String(chunk); const text = String(chunk);
stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT); stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT);
jsonLineBuffer += text; jsonLineBuffer += text;
@@ -612,6 +702,7 @@ async function runCodexLiveExecution(payload, response) {
}); });
child.stderr?.on('data', (chunk) => { child.stderr?.on('data', (chunk) => {
refreshIdleTimer();
const text = String(chunk); const text = String(chunk);
stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT); stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT);
text text
@@ -631,6 +722,7 @@ async function runCodexLiveExecution(payload, response) {
}); });
child.on('error', async (error) => { child.on('error', async (error) => {
clearExecutionTimers();
if (!responseClosed) { if (!responseClosed) {
sendJsonLine(response, { sendJsonLine(response, {
type: 'error', type: 'error',
@@ -643,6 +735,7 @@ async function runCodexLiveExecution(payload, response) {
}); });
child.on('close', async (code) => { child.on('close', async (code) => {
clearExecutionTimers();
const trailingLine = jsonLineBuffer.trim(); const trailingLine = jsonLineBuffer.trim();
if (trailingLine) { if (trailingLine) {
handleCodexJsonLine(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 { import {
ArrowsAltOutlined, ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
SaveOutlined,
PlusOutlined, PlusOutlined,
ShrinkOutlined, ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons'; } 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 { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
@@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() {
}, [automationTypes, selectedAutomationTypeId]); }, [automationTypes, selectedAutomationTypeId]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType)); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [form, isCreating, selectedAutomationType]); }, [detailMode, form, isCreating, selectedAutomationType]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { 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) { if (!hasAccess) {
return ( return (
<Card title="자동화 유형 관리" className="chat-type-management-page"> <Card title="자동화 유형 관리" className="chat-type-management-page">
@@ -250,13 +291,11 @@ export function AutomationTypeManagementPage() {
<Card <Card
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'} title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`} className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
> >
<div className="chat-type-management-page__editor"> <div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null} {errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : 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 <Form
className="chat-type-management-page__editor-form" className="chat-type-management-page__editor-form"
@@ -290,172 +329,150 @@ export function AutomationTypeManagementPage() {
<Form.Item name="id" hidden> <Form.Item name="id" hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}> <div className="chat-type-management-page__editor-scroll">
<Form.Item <div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name" <Form.Item
label="유형명" className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
name="name" label="유형명"
rules={[{ required: true, message: '유형명을 입력하세요.' }]} 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 <Input placeholder="예: 자동화 메모" />
className={`chat-type-management-page__markdown-pane${ </Form.Item>
isMobileViewport && mobileView === 'preview' <Form.Item
? ' chat-type-management-page__markdown-pane--mobile-hidden' className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
: '' label="사용 여부"
}${ name="enabled"
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : '' valuePropName="checked"
}`} >
> <Switch checkedChildren="사용" unCheckedChildren="중지" />
<div className="chat-type-management-page__markdown-pane-header"> </Form.Item>
<Text type="secondary"></Text> </div>
{isMobileViewport ? ( <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 <Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'} type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />} icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => { onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit')); setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}} }}
> >
{maximizedPane === 'edit' ? '축소' : '최대화'} {maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button> </Button>
) : null} <Button
</div> type={maximizedPane === 'preview' ? 'primary' : 'default'}
<Form.Item name="description" noStyle> icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
<Input.TextArea onClick={() => {
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }} setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
className="chat-type-management-page__markdown-textarea" }}
placeholder={ >
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준' {maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
} </Button>
/> </Space>
</Form.Item> )}
</div> </div>
<div <div
className={`chat-type-management-page__markdown-pane${ className={`chat-type-management-page__markdown-grid${
isMobileViewport && mobileView === 'edit' maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
? ' 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${
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"> <div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text> <Text type="secondary"></Text>
{isMobileViewport ? ( {isMobileViewport ? (
<Button <Button
size="small" size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'} type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />} icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => { onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview')); setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}} }}
> >
{maximizedPane === 'preview' ? '축소' : '최대화'} {maximizedPane === 'edit' ? '축소' : '최대화'}
</Button> </Button>
) : null} ) : null}
</div> </div>
<div className="chat-type-management-page__markdown-preview-body"> <Form.Item name="description" noStyle>
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}> <Input.TextArea
{({ getFieldValue }) => { autoSize={isMobileViewport ? false : { minRows: 12, maxRows: 24 }}
const description = String(getFieldValue('description') ?? '').trim(); 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 ? ( return description ? (
<MarkdownPreviewContent content={description} /> <MarkdownPreviewContent content={description} />
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
); );
}} }}
</Form.Item> </Form.Item>
</div>
</div> </div>
</div> </div>
</div> </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> </Form>
</div> </div>
</Card> </Card>

View File

@@ -2,6 +2,8 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@@ -17,21 +19,31 @@
min-height: 0; 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 { .chat-type-management-page .ant-card-head {
min-height: 52px; min-height: 44px;
padding: 0 14px; padding: 0 12px;
} }
.chat-type-management-page .ant-card-head-title, .chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra { .chat-type-management-page .ant-card-extra {
padding: 10px 0; padding: 6px 0;
} }
.chat-type-management-page .ant-card-body { .chat-type-management-page .ant-card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 12px 14px; padding: 4px 14px 12px;
} }
.chat-type-management-page__list, .chat-type-management-page__list,
@@ -40,7 +52,7 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
@@ -57,12 +69,23 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 2px;
overflow: hidden; 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 { .chat-type-management-page__editor-form .ant-form-item {
margin-bottom: 8px; margin-bottom: 6px;
} }
.chat-type-management-page__list-header { .chat-type-management-page__list-header {
@@ -73,7 +96,7 @@
} }
.chat-type-management-page__list-header .ant-typography { .chat-type-management-page__list-header .ant-typography {
margin-bottom: 0; margin: 0;
} }
.chat-type-management-page__item { .chat-type-management-page__item {
@@ -137,15 +160,28 @@
.chat-type-management-page__editor-toolbar { .chat-type-management-page__editor-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 6px; 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 { .chat-type-management-page__markdown-grid {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 6px; gap: 12px;
align-items: stretch; align-items: stretch;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -173,6 +209,15 @@
margin-bottom: 0; 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 { .chat-type-management-page__markdown-pane--desktop-hidden {
display: none; display: none;
} }
@@ -191,13 +236,13 @@
.chat-type-management-page__markdown-textarea { .chat-type-management-page__markdown-textarea {
height: 100% !important; height: 100% !important;
min-height: 180px; min-height: 360px;
resize: none; resize: none;
} }
.chat-type-management-page__markdown-textarea textarea { .chat-type-management-page__markdown-textarea textarea {
height: 100% !important; height: 100% !important;
min-height: 180px; min-height: 360px;
overflow: auto !important; overflow: auto !important;
resize: none; resize: none;
} }
@@ -209,10 +254,10 @@
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
border-radius: 12px; border-radius: 12px;
background: #fafafa; background: #fafafa;
padding: 10px; padding: 10px 12px;
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto minmax(0, 1fr);
gap: 6px; gap: 8px;
overflow: hidden; overflow: hidden;
} }
@@ -226,23 +271,11 @@
margin-bottom: 0; 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 { .chat-type-management-page__meta-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px; gap: 6px 14px;
align-items: start; align-items: end;
} }
.chat-type-management-page__meta-grid--hidden { .chat-type-management-page__meta-grid--hidden {
@@ -251,16 +284,45 @@
.chat-type-management-page__meta-item { .chat-type-management-page__meta-item {
min-width: 0; min-width: 0;
margin-bottom: 0;
} }
.chat-type-management-page__meta-item .ant-form-item-label { .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 { .chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
min-height: 40px; 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,
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form, .chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field, .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
@@ -293,6 +355,23 @@
min-height: 0; 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 { .chat-type-management-page__list-header {
align-items: flex-start; align-items: flex-start;
} }
@@ -305,7 +384,18 @@
.chat-type-management-page .ant-card-head-title, .chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra, .chat-type-management-page .ant-card-extra,
.chat-type-management-page .ant-card-body { .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 { .chat-type-management-page__mobile-toggle {
@@ -314,28 +404,83 @@
.chat-type-management-page__editor-toolbar { .chat-type-management-page__editor-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
} }
.chat-type-management-page__meta-grid { .chat-type-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto;
gap: 6px; 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 { .chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr); 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 { .chat-type-management-page__markdown-pane--mobile-hidden {
display: none; 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,
.chat-type-management-page__markdown-textarea textarea, .chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page__markdown-preview-body { .chat-type-management-page__markdown-preview-body {
min-height: 0; min-height: 0;
} }
.chat-type-management-page__form-actions { .chat-type-management-page__markdown-textarea {
flex-wrap: wrap; 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 { import {
ArrowsAltOutlined, ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
SaveOutlined,
PlusOutlined, PlusOutlined,
ShrinkOutlined, ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons'; } 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 { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
@@ -82,8 +83,13 @@ export function ChatTypeManagementPage() {
}, [chatTypes, selectedChatTypeId]); }, [chatTypes, selectedChatTypeId]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
}, [form, isCreating, selectedChatType]); }, [detailMode, form, isCreating, selectedChatType]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
@@ -145,7 +151,7 @@ export function ChatTypeManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) { if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
return; 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) { if (!hasAccess) {
return ( return (
<Card title="컨텍스트 권한 관리" className="chat-type-management-page"> <Card title="컨텍스트 권한 관리" className="chat-type-management-page">
@@ -267,13 +319,11 @@ export function ChatTypeManagementPage() {
<Card <Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'} title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`} className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
> >
<div className="chat-type-management-page__editor"> <div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null} {errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : 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 <Form
className="chat-type-management-page__editor-form" className="chat-type-management-page__editor-form"
@@ -301,187 +351,165 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden> <Form.Item name="id" hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}> <div className="chat-type-management-page__editor-scroll">
<Form.Item <div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name" <Form.Item
label="컨텍스트명" className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
name="name" label="컨텍스트명"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]} 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 <Input placeholder="예: 운영 문의" />
className={`chat-type-management-page__markdown-pane${ </Form.Item>
isMobileViewport && mobileView === 'preview' <Form.Item
? ' chat-type-management-page__markdown-pane--mobile-hidden' className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
: '' label="사용 권한"
}${ name="permissions"
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : '' >
}`} <Checkbox.Group
> options={[
<div className="chat-type-management-page__markdown-pane-header"> { label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
<Text type="secondary"></Text> { label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
{isMobileViewport ? ( ]}
/>
</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 <Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'} type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />} icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => { onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit')); setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}} }}
> >
{maximizedPane === 'edit' ? '축소' : '최대화'} {maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button> </Button>
) : null} <Button
</div> type={maximizedPane === 'preview' ? 'primary' : 'default'}
<Form.Item name="description" noStyle> icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
<Input.TextArea onClick={() => {
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }} setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
className="chat-type-management-page__markdown-textarea" }}
placeholder={ >
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준' {maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
} </Button>
/> </Space>
</Form.Item> )}
</div> </div>
<div <div
className={`chat-type-management-page__markdown-pane${ className={`chat-type-management-page__markdown-grid${
isMobileViewport && mobileView === 'edit' maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
? ' 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${
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"> <div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text> <Text type="secondary"></Text>
{isMobileViewport ? ( {isMobileViewport ? (
<Button <Button
size="small" size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'} type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />} icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => { onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview')); setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}} }}
> >
{maximizedPane === 'preview' ? '축소' : '최대화'} {maximizedPane === 'edit' ? '축소' : '최대화'}
</Button> </Button>
) : null} ) : null}
</div> </div>
<div className="chat-type-management-page__markdown-preview-body"> <Form.Item name="description" noStyle>
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}> <Input.TextArea
{({ getFieldValue }) => { autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
const description = String(getFieldValue('description') ?? '').trim(); 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 ? ( return description ? (
<MarkdownPreviewContent content={description} /> <MarkdownPreviewContent content={description} />
) : ( ) : (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다." description="미리보기할 문맥 설명이 없습니다."
/> />
); );
}} }}
</Form.Item> </Form.Item>
</div>
</div> </div>
</div> </div>
</div> </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> </Form>
</div> </div>
</Card> </Card>

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,14 @@
cursor: pointer; 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 { .app-header__connection-indicator:hover {
background: #f3f7ff; background: #f3f7ff;
} }
@@ -140,6 +148,28 @@
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22); 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 { @keyframes app-header-connection-badge-pulse {
0%, 0%,
100% { 100% {
@@ -487,6 +517,12 @@
width: 100%; 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) { @media (max-width: 720px) {
html, html,
body, body,
@@ -734,6 +770,17 @@
height: 32px; 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 { .app-header__runtime-summary {
gap: 8px; gap: 8px;
} }
@@ -764,6 +811,11 @@
gap: 8px; gap: 8px;
} }
.app-main-layout:has(.chat-type-management-page) {
padding: 0;
gap: 0;
}
.app-main-window-layer { .app-main-window-layer {
inset: 8px; inset: 8px;
} }

View File

@@ -47,7 +47,7 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'none', id: 'none',
name: '기본유형', name: '기본유형',
description: description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.', '## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
behaviorType: 'none', behaviorType: 'none',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', 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_TYPES_API_PATH = '/chat-types';
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed'; const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000; const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = { export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
guest: '게스트', guest: '게스트',
'token-user': '토큰 사용자', 'token-user': '토큰 사용자',
@@ -50,6 +46,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true, enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z', 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) { 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() { async function fetchChatTypesFromServer() {
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({ const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
method: 'GET', method: 'GET',
@@ -300,7 +243,6 @@ async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
}); });
emitChatTypesChange(); emitChatTypesChange();
clearLegacyChatTypeStorage();
return sanitizeChatTypes(response.chatTypes); return sanitizeChatTypes(response.chatTypes);
} }
@@ -333,7 +275,17 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
return sanitizeChatTypes(chatTypes); 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[] { export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
@@ -359,13 +311,7 @@ export function useChatTypeRegistry() {
try { try {
const serverChatTypes = await fetchChatTypesFromServer(); const serverChatTypes = await fetchChatTypesFromServer();
let resolvedChatTypes = serverChatTypes; const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
if (resolvedChatTypes == null) {
const legacyChatTypes = readLegacyChatTypes();
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
}
if (isMountedRef.current) { if (isMountedRef.current) {
setChatTypesState(resolvedChatTypes); setChatTypesState(resolvedChatTypes);

View File

@@ -18,6 +18,8 @@ import {
type ChatPreviewKind, type ChatPreviewKind,
type ChatPreviewTarget, type ChatPreviewTarget,
} from '../../mainChatPanel/ChatPreviewBody'; } from '../../mainChatPanel/ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl'; import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils'; import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types'; import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -35,12 +37,12 @@ type ConversationRoomPaneProps = {
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; 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 DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
type MessageRenderPayload = { type MessageRenderPayload = {
previewSourceText: string;
visibleText: string; visibleText: string;
diffBlocks: string[]; diffBlocks: string[];
}; };
@@ -132,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl
} }
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] { 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 seen = new Set<string>();
const targets: ChatPreviewTarget[] = []; const targets: ChatPreviewTarget[] = [];
@@ -220,12 +222,10 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.map((match) => match[1]?.trim()) .map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value)); .filter((value): value is string => Boolean(value));
const visibleText = text const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
.replace(DIFF_CODE_BLOCK_PATTERN, '') const visibleText = stripHiddenPreviewTags(previewSourceText);
.replace(/\n{3,}/g, '\n\n')
.trim();
return { visibleText, diffBlocks }; return { previewSourceText, visibleText, diffBlocks };
} }
function isLikelyCollapsibleMessage(text: string) { function isLikelyCollapsibleMessage(text: string) {
@@ -574,8 +574,8 @@ export function ConversationRoomPane({
const isExpandedMessage = expandedMessageIds.includes(message.id); const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text); const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText); const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview = const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');

View File

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

View File

@@ -33,6 +33,7 @@ import {
import { InlineImage } from '../../../components/common/InlineImage'; import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer'; import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody'; import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers'; import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl'; import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils'; import { copyPreviewContent, copyText } from './chatUtils';
@@ -83,7 +84,6 @@ type PreviewFetchError = Error & {
status?: number; status?: number;
}; };
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; 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; const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
type MessageRenderPayload = { type MessageRenderPayload = {
previewSourceText: string;
visibleText: string; visibleText: string;
diffBlocks: 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> { async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = ''; let responseMessage = '';
@@ -199,7 +215,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
} }
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)]; const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>(); const seen = new Set<string>();
const targets: InlinePreviewTarget[] = []; const targets: InlinePreviewTarget[] = [];
@@ -293,9 +309,11 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.map((match) => match[1]?.trim()) .map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value)); .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 { return {
previewSourceText,
visibleText, visibleText,
diffBlocks, diffBlocks,
}; };
@@ -688,6 +706,7 @@ type ChatConversationViewProps = {
previewItems: PreviewOption[]; previewItems: PreviewOption[];
isResourceStripOpen: boolean; isResourceStripOpen: boolean;
isComposerDisabled: boolean; isComposerDisabled: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean; isComposerAttachmentUploading: boolean;
onViewportScroll: () => void; onViewportScroll: () => void;
onViewportTouchEnd: () => void; onViewportTouchEnd: () => void;
@@ -733,6 +752,7 @@ export function ChatConversationView({
previewItems, previewItems,
isResourceStripOpen, isResourceStripOpen,
isComposerDisabled, isComposerDisabled,
isChatTypeSelectionLocked,
isComposerAttachmentUploading, isComposerAttachmentUploading,
onViewportScroll, onViewportScroll,
onViewportTouchEnd, onViewportTouchEnd,
@@ -756,6 +776,7 @@ export function ChatConversationView({
}: ChatConversationViewProps) { }: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]); const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null); const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null); const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]); const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]); const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
@@ -1191,17 +1212,22 @@ export function ChatConversationView({
</label> </label>
<div className="app-chat-panel__resource-strip-list"> <div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => ( {visiblePreviewItems.map((item) => (
<button <InlineMessagePreview
key={item.id} key={item.id}
type="button" target={{
className="app-chat-panel__resource-chip" label: item.label,
onClick={() => { url: item.url,
onOpenPreview(item.id); kind: normalizePreviewOptionKind(item.kind),
}} }}
> isExpanded={expandedResourcePreviewKey === item.id}
<span>{item.label}</span> hasModalPreview
<span>{item.kind}</span> onOpenModalPreview={() => {
</button> onOpenPreview(item.id, { fullscreen: true });
}}
onToggle={() => {
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
}}
/>
))} ))}
</div> </div>
</> </>
@@ -1248,13 +1274,13 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id); const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text); const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) { if (isActivityLogMessage(message)) {
return renderActivityCard(message); return renderActivityCard(message);
} }
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText); const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview = const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
@@ -1498,7 +1524,7 @@ export function ChatConversationView({
), ),
}))} }))}
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
disabled={chatTypeOptions.length === 0} disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked}
onChange={onSelectChatType} onChange={onSelectChatType}
/> />
</div> </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 = { type ChatPreviewBodyProps = {
target: ChatPreviewTarget | null; target: ChatPreviewTarget | null;
previewText: string; previewText: string;
@@ -210,6 +237,7 @@ type ChatPreviewBodyProps = {
previewError: string; previewError: string;
previewContentType?: string; previewContentType?: string;
maxMarkdownBlocks?: number; maxMarkdownBlocks?: number;
renderHtmlAsFrame?: boolean;
}; };
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) { function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
@@ -238,6 +266,7 @@ export function ChatPreviewBody({
previewError, previewError,
previewContentType, previewContentType,
maxMarkdownBlocks, maxMarkdownBlocks,
renderHtmlAsFrame = false,
}: ChatPreviewBodyProps) { }: ChatPreviewBodyProps) {
if (!target) { if (!target) {
return <Empty description="preview 가능한 링크가 아직 없습니다." />; return <Empty description="preview 가능한 링크가 아직 없습니다." />;
@@ -307,6 +336,16 @@ export function ChatPreviewBody({
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') { if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const resolvedLanguage = resolveCodeLanguage(target, previewText); 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') { if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return ( return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code"> <div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">

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 { import {
ArrowLeftOutlined, ArrowLeftOutlined,
DownOutlined,
CheckSquareOutlined, CheckSquareOutlined,
CompressOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
DeleteOutlined, DeleteOutlined,
ArrowsAltOutlined,
EyeOutlined, EyeOutlined,
ExpandOutlined,
FileTextOutlined, FileTextOutlined,
LinkOutlined,
PaperClipOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SaveOutlined, SaveOutlined,
ShrinkOutlined,
UpOutlined,
UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd'; 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 { import {
buildAutomationTypeOptions, buildAutomationTypeOptions,
resolveAutomationTypeLabel, resolveAutomationTypeLabel,
@@ -27,7 +35,7 @@ import {
setupBoard, setupBoard,
updateBoardPost, updateBoardPost,
} from './api'; } from './api';
import type { BoardDraft, BoardPost } from './types'; import type { BoardAttachment, BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -36,15 +44,68 @@ const EMPTY_DRAFT: BoardDraft = {
id: null, id: null,
title: '', title: '',
content: '', content: '',
attachments: [],
automationType: 'none', 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) { function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', { return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul', 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) { async function copyText(value: string) {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
@@ -122,6 +183,8 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() { export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry(); const { automationTypes } = useAutomationTypeRegistry();
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const draftAttachmentSessionIdRef = useRef<string>(createBoardAttachmentSessionId());
const [items, setItems] = useState<BoardPost[]>([]); const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]); const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -129,34 +192,15 @@ export function BoardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [attachmentUploading, setAttachmentUploading] = useState(false);
const [automationReceiving, setAutomationReceiving] = useState(false); const [automationReceiving, setAutomationReceiving] = useState(false);
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null); const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isMobileViewport, setIsMobileViewport] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [contentExpanded, setContentExpanded] = useState(false); const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [attachmentsExpanded, setAttachmentsExpanded] = 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);
};
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; 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( const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null, () => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId], [items, selectedId],
); );
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId); const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
const isDraftLocked = automationReceived; const isDraftLocked = automationReceived;
const draftDirty = Boolean( const draftDirty = Boolean(
@@ -218,6 +283,7 @@ export function BoardPage() {
() => resolveAutomationTypeLabel(automationTypes, draft.automationType), () => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType], [automationTypes, draft.automationType],
); );
const isPaneMaximized = maximizedPane !== 'none';
const receivableIds = useMemo( const receivableIds = useMemo(
() => () =>
items items
@@ -236,6 +302,7 @@ export function BoardPage() {
id: selectedItem.id, id: selectedItem.id,
title: selectedItem.title, title: selectedItem.title,
content: selectedItem.content, content: selectedItem.content,
attachments: selectedItem.attachments,
automationType: selectedItem.automationType, automationType: selectedItem.automationType,
}); });
setAutomationReceiveError(null); setAutomationReceiveError(null);
@@ -252,10 +319,82 @@ export function BoardPage() {
}, [items]); }, [items]);
const handleCreateDraft = () => { const handleCreateDraft = () => {
draftAttachmentSessionIdRef.current = createBoardAttachmentSessionId();
setSelectedId(null); setSelectedId(null);
setDraft(EMPTY_DRAFT); setDraft(EMPTY_DRAFT);
setAutomationReceiveError(null); 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 () => { const handleSave = async () => {
@@ -293,7 +432,7 @@ export function BoardPage() {
return [savedItem, ...filtered]; return [savedItem, ...filtered];
}); });
setSelectedId(savedItem.id); setSelectedId(savedItem.id);
setMobileDetailOpen(isMobileViewport); setDetailMode('detail');
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.'); messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
} catch (error) { } catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.'); messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
@@ -315,7 +454,7 @@ export function BoardPage() {
setItems((previous) => previous.filter((item) => item.id !== draft.id)); setItems((previous) => previous.filter((item) => item.id !== draft.id));
setSelectedId((previous) => (previous === draft.id ? null : previous)); setSelectedId((previous) => (previous === draft.id ? null : previous));
setDraft(EMPTY_DRAFT); setDraft(EMPTY_DRAFT);
setMobileDetailOpen(false); setDetailMode('list');
messageApi.success('게시글을 삭제했습니다.'); messageApi.success('게시글을 삭제했습니다.');
} catch (error) { } catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.'); messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
@@ -441,52 +580,56 @@ export function BoardPage() {
}; };
return ( return (
<Space direction="vertical" size={16} className="board-page"> <div
className={`board-page${detailMode === 'detail' ? ' board-page--detail' : ''}${
isPaneMaximized ? ' board-page--pane-maximized' : ''
}`}
>
{contextHolder} {contextHolder}
<Card className="board-page__card" bordered={false}> <input
<Flex justify="space-between" align="center" gap={16} wrap> ref={attachmentInputRef}
<div> type="file"
<Title level={4} className="board-page__title"> multiple
Plan className="board-page__hidden-file-input"
</Title> onChange={handleAttachmentInputChange}
<Paragraph className="board-page__copy"> />
DB에 . <Space direction="vertical" size={16} className="board-page__stack">
</Paragraph> {detailMode === 'list' ? (
</div> <Card className="board-page__card board-page__overview-card" bordered={false}>
<Space wrap> <Flex justify="space-between" align="center" gap={16} wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}> <div>
<Title level={4} className="board-page__title">
</Button>
<Button </Title>
type="primary" <Paragraph className="board-page__copy">
icon={<SaveOutlined />} , , .
loading={saving} </Paragraph>
disabled={isDraftLocked} </div>
onClick={() => { <Space wrap>
void handleSave(); <Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
}}
> </Button>
</Space>
</Button> </Flex>
</Space> </Card>
</Flex> ) : null}
</Card>
{errorMessage ? ( {errorMessage && detailMode === 'list' ? (
<Card className="board-page__card" bordered={false}> <Card className="board-page__card" bordered={false}>
<Text type="danger">{errorMessage}</Text> <Text type="danger">{errorMessage}</Text>
</Card> </Card>
) : null} ) : null}
<div className="board-page__grid"> {detailMode === 'list' ? (
<Card <Card
title={`게시글 목록 (${items.length})`} title={`게시글 목록 (${items.length})`}
className={`board-page__card board-page__list-card${ className="board-page__card board-page__list-card"
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
}`}
bordered={false} bordered={false}
extra={ extra={
<Space size={8} wrap> <Space size={8} wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
{loading ? <Spin size="small" /> : null} {loading ? <Spin size="small" /> : null}
<Text type="secondary" className="board-page__bulk-count"> <Text type="secondary" className="board-page__bulk-count">
{checkedReceivableCount} {checkedReceivableCount}
@@ -528,15 +671,12 @@ export function BoardPage() {
<List <List
dataSource={items} dataSource={items}
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'} className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
onClick={() => { onClick={() => {
setSelectedId(item.id); handleOpenDetail(item.id);
if (isMobileViewport) { }}
setMobileDetailOpen(true); >
}
}}
>
<List.Item.Meta <List.Item.Meta
avatar={ avatar={
<Checkbox <Checkbox
@@ -562,6 +702,7 @@ export function BoardPage() {
</Flex> </Flex>
<Space size={6} wrap> <Space size={6} wrap>
{item.id === dirtyDraftId ? <Tag color="warning"> </Tag> : null} {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'}> <Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'} {item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
</Tag> </Tag>
@@ -584,151 +725,335 @@ export function BoardPage() {
<Empty description="등록된 게시글이 없습니다." /> <Empty description="등록된 게시글이 없습니다." />
)} )}
</Card> </Card>
) : (
<div <div className="board-page__editor-column">
className={`board-page__editor-column${
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
}`}
>
<Card <Card
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'} 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} bordered={false}
extra={ extra={
<Space wrap> <Space wrap className="board-page__header-actions">
{isMobileViewport && mobileDetailOpen ? ( <Button icon={<ArrowLeftOutlined />} aria-label="목록으로" title="목록으로" onClick={handleCloseDetail} />
<Button <Button icon={<PlusOutlined />} onClick={handleCreateDraft} aria-label="새 글" title="새 글" />
icon={<ArrowLeftOutlined />} <Button
onClick={() => { type="primary"
setMobileDetailOpen(false); icon={<SaveOutlined />}
}} aria-label="저장"
> title="저장"
loading={saving}
</Button> disabled={isDraftLocked}
) : null} onClick={() => {
void handleSave();
}}
/>
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null} {draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
{draft.id && selectedItem?.automationPlanItemId ? ( {draft.id && selectedItem?.automationPlanItemId ? (
<Button <Button
icon={<LinkOutlined />}
aria-label="연결 자동화 열기"
title="연결 자동화 열기"
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`} href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> />
</Button>
) : null} ) : null}
{draft.id ? ( {draft.id ? (
<Button <Button
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
aria-label={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
title={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
loading={automationReceiving} loading={automationReceiving}
disabled={automationReceived && !automationReceiveError} disabled={automationReceived && !automationReceiveError}
onClick={() => { onClick={() => {
void handleAutomationReceive(); void handleAutomationReceive();
}} }}
> />
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
</Button>
) : null}
{draft.id ? (
<Button
danger
icon={<DeleteOutlined />}
loading={deleting}
disabled={isDraftLocked}
onClick={() => {
void handleDelete();
}}
>
</Button>
) : null} ) : null}
<Button
danger
icon={<DeleteOutlined />}
aria-label="삭제"
title="삭제"
loading={deleting}
disabled={!draft.id || isDraftLocked}
onClick={() => {
void handleDelete();
}}
/>
</Space> </Space>
} }
> >
<Space direction="vertical" size={16} className="board-page__editor"> <div className="board-page__editor">
<Input {errorMessage ? <Text type="danger">{errorMessage}</Text> : null}
size="large" <div className="board-page__editor-scroll">
placeholder="제목을 입력하세요" <div className={`board-page__meta-stack${isPaneMaximized ? ' board-page__meta-stack--hidden' : ''}`}>
value={draft.title} <div className="board-page__hero">
readOnly={isDraftLocked} <div className="board-page__hero-main">
onChange={(event) => { <div className="board-page__field-label-row">
setDraft((previous) => ({ <Text strong> </Text>
...previous, <Flex gap={8} wrap>
title: event.target.value, <Tag color={automationStatus.color}>{automationStatus.label}</Tag>
})); {automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
}} </Flex>
/> </div>
<Flex gap={8} wrap> <Input
<Tag color={automationStatus.color}>{automationStatus.label}</Tag> size="large"
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null} placeholder="예: 작업요청 입력 폼을 전면 개편하고 첨부 자동 전달 연결"
</Flex> value={draft.title}
<div className="board-page__automation-field"> readOnly={isDraftLocked}
<Text strong> </Text> onChange={(event) => {
{automationReceived ? ( setDraft((previous) => ({
<div className="board-page__automation-readonly" aria-readonly="true"> ...previous,
<Text>{automationTypeLabel}</Text> title: event.target.value,
<Tag color="processing"> </Tag> }));
}}
/>
</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> </div>
) : ( <div className="board-page__attachment-panel">
<Select <Flex justify="space-between" align="center" gap={12} wrap>
className="board-page__automation-select" <div>
value={draft.automationType} <div className="board-page__field-label-row">
options={automationTypeOptions} <Text strong> </Text>
popupClassName="board-page__automation-select-popup" <Tag color={draft.attachments.length ? 'blue' : 'default'}>
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body} {draft.attachments.length}
disabled={isDraftLocked} </Tag>
onChange={(automationType) => { </div>
setDraft((previous) => ({ <Text type="secondary"> .</Text>
...previous, </div>
automationType, <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>
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}> {isPaneMaximized ? (
{contentExpanded ? ( <div className="board-page__floating-toolbar">
<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>
<Space size={8}> <Space size={8}>
<Button <Button
size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
aria-label="본문 복사" aria-label="본문 복사"
title="본문 복사" title="본문 복사"
@@ -737,60 +1062,22 @@ export function BoardPage() {
}} }}
/> />
<Button <Button
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />} size="small"
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'} icon={<ShrinkOutlined />}
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'} aria-label="편집 보기로 복귀"
title="편집 보기로 복귀"
onClick={() => { onClick={() => {
setContentExpanded((previous) => !previous); setMaximizedPane('none');
}} }}
/> />
</Space> </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> </div>
{isDraftLocked ? ( ) : null}
<Text type="secondary"> .</Text> </div>
) : null}
</div>
</Space>
</Card> </Card>
</div> </div>
</div> )}
</Space> </Space>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity'; import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess'; import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess'; 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 { class BoardApiError extends Error {
status: number; status: number;
@@ -17,6 +17,37 @@ function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
return normalizeAutomationTypeId(value); 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() { function resolveBoardApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) { if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL; return import.meta.env.VITE_WORK_SERVER_URL;
@@ -134,10 +165,7 @@ export async function setupBoard() {
export async function fetchBoardPosts() { export async function fetchBoardPosts() {
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts'); const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
return response.items.map((item) => ({ return response.items.map((item) => normalizeBoardPost(item));
...item,
automationType: normalizeBoardAutomationType(item.automationType),
}));
} }
export async function createBoardPost(draft: BoardDraft) { export async function createBoardPost(draft: BoardDraft) {
@@ -146,14 +174,12 @@ export async function createBoardPost(draft: BoardDraft) {
body: JSON.stringify({ body: JSON.stringify({
title: draft.title, title: draft.title,
content: draft.content, content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType, automationType: draft.automationType,
}), }),
}); });
return { return normalizeBoardPost(response.item);
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
} }
export async function updateBoardPost(draft: BoardDraft) { export async function updateBoardPost(draft: BoardDraft) {
@@ -166,14 +192,12 @@ export async function updateBoardPost(draft: BoardDraft) {
body: JSON.stringify({ body: JSON.stringify({
title: draft.title, title: draft.title,
content: draft.content, content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType, automationType: draft.automationType,
}), }),
}); });
return { return normalizeBoardPost(response.item);
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
} }
export async function receiveBoardPostAutomation(id: number) { export async function receiveBoardPostAutomation(id: number) {
@@ -188,10 +212,7 @@ export async function receiveBoardPostAutomation(id: number) {
}); });
return { return {
item: { item: normalizeBoardPost(response.item),
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
},
planItemId: response.planItemId, planItemId: response.planItemId,
alreadyReceived: response.alreadyReceived, alreadyReceived: response.alreadyReceived,
}; };

View File

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

View File

@@ -1022,7 +1022,44 @@ button,
.board-page { .board-page {
width: 100%; width: 100%;
height: 100%;
min-width: 0; 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 { .board-page__card {
@@ -1031,6 +1068,18 @@ button,
box-shadow: none; 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 { .board-page__title.ant-typography {
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -1039,17 +1088,12 @@ button,
margin-bottom: 0; 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 { .board-page__editor-column {
position: relative; position: relative;
z-index: 2; z-index: 2;
flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0;
} }
.board-page__list-card .ant-card-body, .board-page__list-card .ant-card-body,
@@ -1057,6 +1101,16 @@ button,
min-width: 0; 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__list-card--mobile-hidden,
.board-page__editor-column--mobile-hidden { .board-page__editor-column--mobile-hidden {
display: none; display: none;
@@ -1065,7 +1119,18 @@ button,
.board-page__editor-card, .board-page__editor-card,
.board-page__editor-card .ant-card-body { .board-page__editor-card .ant-card-body {
position: relative; 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 { .board-page__list-item {
@@ -1103,18 +1168,42 @@ button,
font-weight: 600; font-weight: 600;
} }
.board-page__editor.ant-space { .board-page__editor {
width: 100%; width: 100%;
flex: 1 1 auto;
min-height: 0; 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%; width: 100%;
flex: 1 1 auto;
min-width: 0; 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 { .board-page__header-actions {
min-height: 0; 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 { .board-page__automation-field {
@@ -1124,10 +1213,72 @@ button,
gap: 8px; 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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; gap: 12px;
min-height: 40px; min-height: 40px;
padding: 9px 12px; padding: 9px 12px;
@@ -1170,29 +1321,47 @@ button,
display: none; display: none;
} }
.board-page__editor-frame { .board-page__hidden-file-input {
position: relative; display: none;
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-height: 0;
} }
.board-page__editor-frame--expanded { .board-page__markdown-field {
position: fixed; width: 100%;
inset: 0; flex: 1 1 auto;
z-index: 1300; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 6px;
padding: 24px; overflow: hidden;
background: #fff; }
.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; overflow: hidden;
} }
.board-page__editor-toolbar { .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 { .board-page__preview-grid {
@@ -1201,63 +1370,65 @@ button,
gap: 16px; gap: 16px;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 0;
}
.board-page__preview-grid--expanded {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden;
}
.board-page__preview-grid--maximized {
grid-template-columns: minmax(0, 1fr);
} }
.board-page__pane { .board-page__pane {
width: 100%;
min-width: 0; min-width: 0;
height: 100%;
min-height: 0; 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; display: flex;
flex: 1 1 0; align-items: center;
width: 100%; justify-content: space-between;
min-height: 0; gap: 8px;
} }
.board-page__textarea.ant-input { .board-page__textarea.ant-input {
flex: 1 1 auto;
height: 100%;
min-height: 520px; min-height: 520px;
font-family: font-family:
'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace; 'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace;
line-height: 1.6; line-height: 1.6;
resize: vertical; resize: none;
}
.board-page__textarea--expanded.ant-input {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
} }
.board-page__preview { .board-page__preview {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 520px; min-height: 520px;
border: 1px solid rgba(22, 93, 255, 0.12); border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px; border-radius: 14px;
background: #ffffff; background: #ffffff;
padding: 18px; padding: 18px;
overflow: auto; overflow: hidden;
} }
.board-page__preview-content { .board-page__preview-content {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} overflow: auto;
.board-page__preview--expanded {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
min-width: 0;
} }
.board-page__loading { .board-page__loading {
@@ -1266,6 +1437,16 @@ button,
min-height: 220px; 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 { .release-pending-main-modal .ant-modal-content {
border-radius: 24px; border-radius: 24px;
} }
@@ -1493,6 +1674,48 @@ button,
} }
@media (max-width: 960px) { @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 { .history-page__filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1505,10 +1728,25 @@ button,
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.board-page__grid { .board-page__hero {
grid-template-columns: 1fr; 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__list-card .ant-card-body,
.history-page__detail-card .ant-card-body, .history-page__detail-card .ant-card-body,
.chat-source-changes-page__list-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__textarea.ant-input,
.board-page__preview { .board-page__preview {
min-height: 360px; min-height: 0;
}
.board-page__textarea--expanded.ant-input,
.board-page__preview--expanded {
min-height: calc(100vh - 148px);
height: calc(100vh - 148px);
} }
.release-pending-main-modal .ant-modal { .release-pending-main-modal .ant-modal {
@@ -1554,20 +1786,28 @@ button,
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.board-page__grid {
grid-template-columns: 1fr;
}
.board-page__mobile-toggle { .board-page__mobile-toggle {
display: inline-flex; display: inline-flex;
} }
.board-page__preview-grid { .board-page__editor-toolbar {
grid-template-columns: 1fr; flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
} }
.board-page__editor-frame--expanded { .board-page__desktop-toolbar {
padding: 16px; 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 { .board-page__automation-readonly {
@@ -1579,6 +1819,36 @@ button,
display: none; 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 { .release-review-page__toolbar {
align-items: stretch; align-items: stretch;
} }