import { ArrowLeftOutlined, CloseOutlined, CopyOutlined, DownloadOutlined, DownOutlined, FullscreenExitOutlined, FullscreenOutlined, PaperClipOutlined, UpOutlined, } from '@ant-design/icons'; import { Alert, Button, Card, Checkbox, Empty, Flex, Grid, Input, List, Modal, Segmented, Select, Space, Spin, Tag, Tabs, Typography, message, } from 'antd'; import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { useAppConfig, type AppConfig } from '../../app/main/appConfig'; import { buildAutomationTypeOptions, resolveAutomationTypeLabel, useAutomationTypeRegistry, } from '../../app/main/automationTypeAccess'; import { uploadChatComposerFile } from '../../app/main/mainChatPanel'; import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl'; import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types'; import { useTokenAccess } from '../../app/main/tokenAccess'; import './planBoard.css'; import { type EvidenceAttachmentItem, EvidenceAttachmentPreviewBody, EvidenceAttachmentStrip, } from '../../components/evidenceAttachmentStrip'; import { CodexDiffPreviewer, PreviewerUI } from '../../components/previewer'; import { appendPlanActionHistory, appendPlanIssueAction, createPlanItem, deletePlanItem, type PlanApiRequestMeta, fetchPlanActionHistories, fetchPlanIssueHistories, fetchPlanItemsWithLatestSourceWorks, fetchReleaseReviewBoardItems, fetchPlanSourceWorkHistories, fetchPlanSourceWorkHistory, runPlanAction, setupPlanBoard, updatePlanItemJangsingProcessingRequired, updatePlanItem, } from './api'; import { getPlanQuickFilterLabel, isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem, type PlanQuickFilter, } from './quickFilters'; import { PlanListDetailLayout } from './PlanListDetailLayout'; import { maskNotePreviewByWord } from './noteMasking'; import type { PlanActionHistory, PlanActionType, PlanAutomationUsageSnapshot, PlanDraft, PlanFilterStatus, PlanIssueHistory, PlanItem, PlanReleaseReviewBoardItem, PlanSourceWorkHistory, } from './types'; const { Paragraph, Text, Title } = Typography; const { TextArea } = Input; const statusTagColorMap: Record = { 등록: 'default', 작업중: 'processing', 작업완료: 'cyan', 릴리즈완료: 'geekblue', 완료: 'success', }; const ACTIVE_WORKER_STATUSES = new Set([ '브랜치생성중', '자동작업중', 'release반영중', 'main반영중', ]); const AUTO_REFRESH_CANDIDATE_WORKER_STATUSES = new Set([ ...ACTIVE_WORKER_STATUSES, '브랜치준비', 'release반영대기', 'main반영대기', ]); const AUTO_REFRESH_LONG_PRESS_MS = 700; const COLLAPSIBLE_DETAIL_MAX_LINES = 4; const COLLAPSIBLE_DETAIL_MAX_CHARS = 180; const PLAN_LIST_PAGE_SIZE = 10; type WorkerStateFilter = 'all' | 'active' | 'waiting' | 'failed' | 'done' | 'idle'; type ReleaseStateFilter = 'all' | 'pending' | 'merged' | 'failed'; type MainStateFilter = 'all' | 'pending' | 'merged' | 'failed' | 'not-targeted'; type IssueStateFilter = 'all' | 'open' | 'none'; type CostStateFilter = 'all' | 'recorded' | 'none' | 'stable' | 'attention' | 'warning' | 'high'; const WORKER_STATE_FILTER_OPTIONS: Array<{ label: string; value: WorkerStateFilter }> = [ { label: '작업자 전체', value: 'all' }, { label: '진행중', value: 'active' }, { label: '대기', value: 'waiting' }, { label: '실패', value: 'failed' }, { label: '완료', value: 'done' }, { label: '미시작', value: 'idle' }, ]; const RELEASE_STATE_FILTER_OPTIONS: Array<{ label: string; value: ReleaseStateFilter }> = [ { label: 'release 전체', value: 'all' }, { label: 'release 대기/진행', value: 'pending' }, { label: 'release 완료', value: 'merged' }, { label: 'release 실패', value: 'failed' }, ]; const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }> = [ { label: 'main 전체', value: 'all' }, { label: 'main 대기/진행', value: 'pending' }, { label: 'main 완료', value: 'merged' }, { label: 'main 실패', value: 'failed' }, { label: 'main 미대상', value: 'not-targeted' }, ]; const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [ { label: '이슈 전체', value: 'all' }, { label: '열린 이슈', value: 'open' }, { label: '이슈 없음', value: 'none' }, ]; const COST_STATE_FILTER_OPTIONS: Array<{ label: string; value: CostStateFilter }> = [ { label: '비용상태 전체', value: 'all' }, { label: '비용 기록 있음', value: 'recorded' }, { label: '비용 기록 없음', value: 'none' }, { label: '안정', value: 'stable' }, { label: '관심', value: 'attention' }, { label: '주의', value: 'warning' }, { label: '높음', value: 'high' }, ]; type ReviewListIndicator = { status: PlanReleaseReviewBoardItem['review']['status']; checkedCount: number; totalCount: number; }; type PlanNoteResource = { id: string; label: string; sourcePath: string; publicUrl: string; previewType: 'image' | 'document' | 'link'; }; const PLAN_NOTE_RESOURCE_LINE_PATTERN = /^\s*-\s+(.+?):\s+((?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+))\s*$/; const PLAN_NOTE_RESOURCE_GLOBAL_PATTERN = /(?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)/g; const PLAN_NOTE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']); const PLAN_NOTE_DOCUMENT_EXTENSIONS = new Set([ 'pdf', 'txt', 'md', 'json', 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'css', 'html', 'diff', 'log', ]); function createPlanNoteAttachmentSessionId() { return `plan-note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function normalizePlanNoteResourceSourcePath(value: string) { return String(value ?? '') .trim() .replace(/[)>.,]+$/, '') .replace(/^\/+/, '/') .replace(/^public\/(?=\.codex_chat\/)/, ''); } function normalizePlanNoteResourceUrl(value: string) { const normalizedSourcePath = normalizePlanNoteResourceSourcePath(value); if (!normalizedSourcePath) { return ''; } if (normalizedSourcePath.startsWith('/api/chat/resources/')) { return normalizeChatResourceUrl(normalizedSourcePath); } if (normalizedSourcePath.startsWith('/.codex_chat/')) { return normalizeChatResourceUrl(normalizedSourcePath); } if (normalizedSourcePath.startsWith('.codex_chat/')) { return normalizeChatResourceUrl(`/${normalizedSourcePath}`); } return normalizeChatResourceUrl(normalizedSourcePath); } function getPlanNoteResourceBaseName(sourcePath: string) { const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, ''); const segments = normalized.split('/').filter(Boolean); return segments.at(-1) ?? normalized; } function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResource['previewType'] { const baseName = getPlanNoteResourceBaseName(sourcePath); const extension = baseName.includes('.') ? baseName.split('.').at(-1)?.toLowerCase() ?? '' : ''; if (PLAN_NOTE_IMAGE_EXTENSIONS.has(extension)) { return 'image'; } if (PLAN_NOTE_DOCUMENT_EXTENSIONS.has(extension)) { return 'document'; } return 'link'; } function extractPlanNoteResources(note: string) { const normalizedNote = String(note ?? ''); const lineEntries = normalizedNote .split(/\r?\n/) .map((line) => { const matched = line.match(PLAN_NOTE_RESOURCE_LINE_PATTERN); if (!matched) { return null; } return { label: matched[1]?.trim() || getPlanNoteResourceBaseName(matched[2] ?? ''), sourcePath: normalizePlanNoteResourceSourcePath(matched[2] ?? ''), }; }) .filter((item): item is { label: string; sourcePath: string } => Boolean(item?.sourcePath)); const seen = new Set(lineEntries.map((item) => item.sourcePath)); const genericEntries = Array.from(normalizedNote.matchAll(PLAN_NOTE_RESOURCE_GLOBAL_PATTERN)) .map((matched) => normalizePlanNoteResourceSourcePath(matched[0] ?? '')) .filter(Boolean) .filter((sourcePath) => { if (seen.has(sourcePath)) { return false; } seen.add(sourcePath); return true; }) .map((sourcePath) => ({ label: getPlanNoteResourceBaseName(sourcePath), sourcePath, })); return [...lineEntries, ...genericEntries].map((item, index) => ({ id: `${index}-${item.sourcePath}`, label: item.label, sourcePath: item.sourcePath, publicUrl: normalizePlanNoteResourceUrl(item.sourcePath), previewType: resolvePlanNoteResourcePreviewType(item.sourcePath), })); } function appendPlanNoteAttachments(note: string, attachments: ChatComposerAttachment[]) { if (attachments.length === 0) { return note; } const existingSourcePaths = new Set(extractPlanNoteResources(note).map((item) => item.sourcePath)); const nextLines = attachments .map((attachment) => { const sourcePath = normalizePlanNoteResourceSourcePath(attachment.path); return { label: attachment.name.trim() || getPlanNoteResourceBaseName(sourcePath), sourcePath, }; }) .filter((item) => item.sourcePath) .filter((item) => { if (existingSourcePaths.has(item.sourcePath)) { return false; } existingSourcePaths.add(item.sourcePath); return true; }) .map((item) => `- ${item.label}: ${item.sourcePath}`); if (nextLines.length === 0) { return note; } const currentNote = note.trimEnd(); const attachmentSectionPattern = /(^|\n)첨부 파일:\n(?:- .+\n?)*/; const matchedSection = currentNote.match(attachmentSectionPattern); if (!matchedSection || matchedSection.index === undefined) { return `${currentNote}${currentNote ? '\n\n' : ''}첨부 파일:\n${nextLines.join('\n')}`; } const startIndex = matchedSection.index; const matchedText = matchedSection[0]; const insertIndex = startIndex + matchedText.length; const prefix = currentNote.slice(0, insertIndex).replace(/\n*$/, '\n'); const suffix = currentNote.slice(insertIndex).replace(/^\n+/, '\n'); return `${prefix}${nextLines.join('\n')}${suffix}`; } function resolvePlanNoteAttachmentSessionId( draftId: number | null, fallbackSessionId: string, ) { if (draftId) { return `plan-note-${draftId}`; } return fallbackSessionId; } function isPlanItemRequestLocked(item: Pick | null | undefined) { return Boolean(item?.startedAt); } const NON_RETRY_NOTIFICATION_PATTERNS = [ '다시 실행하지 않았습니다.', '재처리는 요청하지 않았습니다.', '자동 재처리 대상은 없습니다.', ] as const; const TOKEN_ACCESS_REQUIRED_MESSAGE = '설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 자동화 조회 외 기능을 사용할 수 있습니다.'; function formatPlanNotificationLabel(item: Pick) { const normalizedWorkId = item.workId.trim().replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); if (!normalizedWorkId || normalizedWorkId === '작업id' || normalizedWorkId === 'workid') { return `#${item.id}`; } return item.workId; } function formatPlanListLabel(item: Pick) { const normalizedWorkId = item.workId.trim().replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); if (!normalizedWorkId || normalizedWorkId === '작업id' || normalizedWorkId === 'workid') { return `#${item.id}`; } return `#${item.id} ${item.workId}`; } function formatPlanDateTime(value: string | null | undefined) { if (!value) { return '미기록'; } return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', }); } function formatResponseBytes(value: number | null | undefined) { const bytes = Number(value ?? 0); if (!Number.isFinite(bytes) || bytes <= 0) { return '0 B'; } if (bytes >= 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } if (bytes >= 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${Math.round(bytes)} B`; } function getReleaseReviewListTagColor(status: PlanReleaseReviewBoardItem['review']['status']) { if (status === 'approved') { return 'success'; } if (status === 'changes-requested') { return 'error'; } if (status === 'reviewing') { return 'processing'; } return 'default'; } function getReleaseReviewListTagLabel(review: ReviewListIndicator) { const progressLabel = review.totalCount > 0 ? ` ${review.checkedCount}/${review.totalCount}` : ''; if (review.status === 'approved') { return `검수완료${progressLabel}`; } if (review.status === 'changes-requested') { return `수정필요${progressLabel}`; } if (review.status === 'reviewing') { return `검수중${progressLabel}`; } return review.totalCount > 0 ? `검수대기 ${review.checkedCount}/${review.totalCount}` : '검수대기'; } function resolvePlanHistorySuccessMessage(message: string | undefined, fallback: string) { if (!message) { return fallback; } if (NON_RETRY_NOTIFICATION_PATTERNS.some((pattern) => message.includes(pattern))) { return fallback; } return message; } function resolveProtectedText(text: string | null | undefined, masked: boolean) { if (!masked) { return text?.trim() ? text : '내용이 없습니다.'; } return maskNotePreviewByWord(text ?? ''); } function isCollapsibleDetailText(value: string) { const trimmedValue = value.trim(); if (!trimmedValue) { return false; } return trimmedValue.length > COLLAPSIBLE_DETAIL_MAX_CHARS || trimmedValue.split(/\r?\n/).length > COLLAPSIBLE_DETAIL_MAX_LINES; } function ExpandableDetailText({ text, type, }: { text: string; type?: 'secondary' | 'success' | 'warning' | 'danger'; }) { const [expanded, setExpanded] = useState(false); const collapsible = isCollapsibleDetailText(text); return (
{text} {collapsible ? (
); } function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) { return (
첨부 리소스 {resources.length}건
{resources.map((resource) => (
{resource.label} {resource.sourcePath} {resource.previewType === 'image' ? ( {resource.label} ) : null} {resource.previewType === 'document' ? (